Compare commits

...

24 Commits

Author SHA1 Message Date
metacryst
8d89137037 Merge branch 'main' of https://git.sun.museum/sam/ForumMobile 2026-06-25 16:43:41 -05:00
metacryst
74f488f242 Include dist files 2026-06-25 16:43:38 -05:00
92cc6e779a Merge branch 'main' of https://git.sun.museum/sam/ForumMobile 2026-06-23 11:28:02 -04:00
metacryst
92eb626cb9 Improve splash screen 2026-06-22 06:19:28 -05:00
metacryst
882d2767f9 Native Sidebar 2026-06-22 04:35:01 -05:00
c0b1e95204 Merge branch 'main' of https://git.sun.museum/sam/ForumMobile 2026-06-21 14:55:43 -04:00
914c98852b Added hls.min.js import from server in index.html 2026-06-21 14:55:38 -04:00
metacryst
21cc89f0af [Notifications] Network photos in notifications 2026-06-21 05:00:55 -05:00
metacryst
8453819f50 [Chat] Native input bar 2026-06-18 05:07:41 -05:00
5304ab1d6d iOS Notification Service 2026-05-07 14:07:02 -04:00
metacryst
69469fa2ad Swipe navigation library 2026-04-19 21:56:09 -05:00
metacryst
142d4a3fc3 Allow fetching from multiple sources 2026-04-16 18:27:24 -05:00
metacryst
0bb544e96c update version, incorporate error screen 2026-04-10 03:19:22 -05:00
metacryst
a104b43b24 error handling for no internet 2026-04-09 07:42:56 -05:00
metacryst
8d5a63b262 fetch everything remotely 2026-04-09 06:22:32 -05:00
14c885a60f Animation conflict fix 2026-04-06 16:47:07 -04:00
422e89caee Fixed serverFunctions naming issue 2026-04-06 12:25:47 -04:00
d0df371a3c Sidebar controls for calendar
- Added toggle for switching between day/week/month (calendar) in the sidebar
- Added a "lightdivider" shared.css var
2026-04-04 20:32:40 -04:00
metacryst
a1803a6f4a Merge branch 'main' of https://git.sun.museum/sam/ForumMobile 2026-04-04 01:41:37 -05:00
metacryst
3fe1386415 remote url changed 2026-04-04 01:41:35 -05:00
a306f0732c Removed /db from getImageURL 2026-04-03 19:23:07 -04:00
027ae4d130 Added CSS var and /@server on viite config 2026-04-03 13:56:17 -04:00
metacryst
2592137fa3 quill updates, signup comment 2026-03-31 07:08:07 -05:00
metacryst
8279a81dc9 updating quill 2026-03-28 02:24:04 -05:00
117 changed files with 2137 additions and 4410 deletions

2
.gitignore vendored
View File

@@ -4,5 +4,5 @@ node_modules/
*.map
.DS_Store
.sourcemaps
dist/
/dist/
package-lock.json

View File

@@ -11,7 +11,7 @@
},
"plugins": {
"SplashScreen": {
"launchAutoHide": true
"launchAutoHide": false
}
}
}

View File

@@ -14,9 +14,35 @@
504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; };
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
5ADE813A2FAD0ACB008DDDE2 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ADE81392FAD0ACB008DDDE2 /* NotificationService.swift */; };
5ADE813E2FAD0ACB008DDDE2 /* NotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 5ADE81372FAD0ACA008DDDE2 /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
5ADE813C2FAD0ACB008DDDE2 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 504EC2FC1FED79650016851F /* Project object */;
proxyType = 1;
remoteGlobalIDString = 5ADE81362FAD0ACA008DDDE2;
remoteInfo = NotificationService;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
5ADE813F2FAD0ACB008DDDE2 /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
5ADE813E2FAD0ACB008DDDE2 /* NotificationService.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
22352DD22F74F93C0052EF07 /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = "<group>"; };
2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = "<group>"; };
@@ -28,6 +54,9 @@
504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
5ADE81372FAD0ACA008DDDE2 /* NotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; };
5ADE81392FAD0ACB008DDDE2 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
5ADE813B2FAD0ACB008DDDE2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App.framework; sourceTree = BUILT_PRODUCTS_DIR; };
AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = "<group>"; };
FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = "<group>"; };
@@ -42,6 +71,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
5ADE81342FAD0ACA008DDDE2 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
@@ -57,6 +93,7 @@
isa = PBXGroup;
children = (
504EC3061FED79650016851F /* App */,
5ADE81382FAD0ACB008DDDE2 /* NotificationService */,
504EC3051FED79650016851F /* Products */,
7F8756D8B27F46E3366F6CEA /* Pods */,
27E2DDA53C4D2A4D1A88CE4A /* Frameworks */,
@@ -67,6 +104,7 @@
isa = PBXGroup;
children = (
504EC3041FED79650016851F /* App.app */,
5ADE81372FAD0ACA008DDDE2 /* NotificationService.appex */,
);
name = Products;
sourceTree = "<group>";
@@ -87,6 +125,15 @@
path = App;
sourceTree = "<group>";
};
5ADE81382FAD0ACB008DDDE2 /* NotificationService */ = {
isa = PBXGroup;
children = (
5ADE81392FAD0ACB008DDDE2 /* NotificationService.swift */,
5ADE813B2FAD0ACB008DDDE2 /* Info.plist */,
);
path = NotificationService;
sourceTree = "<group>";
};
7F8756D8B27F46E3366F6CEA /* Pods */ = {
isa = PBXGroup;
children = (
@@ -109,23 +156,42 @@
504EC3021FED79650016851F /* Resources */,
9592DBEFFC6D2A0C8D5DEB22 /* [CP] Embed Pods Frameworks */,
E3424294FACA667401D6F46C /* [CP] Copy Pods Resources */,
5ADE813F2FAD0ACB008DDDE2 /* Embed Foundation Extensions */,
);
buildRules = (
);
dependencies = (
5ADE813D2FAD0ACB008DDDE2 /* PBXTargetDependency */,
);
name = App;
productName = App;
productReference = 504EC3041FED79650016851F /* App.app */;
productType = "com.apple.product-type.application";
};
5ADE81362FAD0ACA008DDDE2 /* NotificationService */ = {
isa = PBXNativeTarget;
buildConfigurationList = 5ADE81422FAD0ACB008DDDE2 /* Build configuration list for PBXNativeTarget "NotificationService" */;
buildPhases = (
5ADE81332FAD0ACA008DDDE2 /* Sources */,
5ADE81342FAD0ACA008DDDE2 /* Frameworks */,
5ADE81352FAD0ACA008DDDE2 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = NotificationService;
productName = NotificationService;
productReference = 5ADE81372FAD0ACA008DDDE2 /* NotificationService.appex */;
productType = "com.apple.product-type.app-extension";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
504EC2FC1FED79650016851F /* Project object */ = {
isa = PBXProject;
attributes = {
LastSwiftUpdateCheck = 0920;
LastSwiftUpdateCheck = 1500;
LastUpgradeCheck = 0920;
TargetAttributes = {
504EC3031FED79650016851F = {
@@ -133,6 +199,10 @@
LastSwiftMigration = 1100;
ProvisioningStyle = Automatic;
};
5ADE81362FAD0ACA008DDDE2 = {
CreatedOnToolsVersion = 15.0;
ProvisioningStyle = Automatic;
};
};
};
buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */;
@@ -149,6 +219,7 @@
projectRoot = "";
targets = (
504EC3031FED79650016851F /* App */,
5ADE81362FAD0ACA008DDDE2 /* NotificationService */,
);
};
/* End PBXProject section */
@@ -167,6 +238,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
5ADE81352FAD0ACA008DDDE2 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
@@ -229,8 +307,24 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
5ADE81332FAD0ACA008DDDE2 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
5ADE813A2FAD0ACB008DDDE2 /* NotificationService.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
5ADE813D2FAD0ACB008DDDE2 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 5ADE81362FAD0ACA008DDDE2 /* NotificationService */;
targetProxy = 5ADE813C2FAD0ACB008DDDE2 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
504EC30B1FED79650016851F /* Main.storyboard */ = {
isa = PBXVariantGroup;
@@ -362,6 +456,7 @@
isa = XCBuildConfiguration;
baseConfigurationReference = FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
@@ -370,7 +465,7 @@
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.0.5;
MARKETING_VERSION = 1.0.8;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = russell.sam.forum;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -384,6 +479,7 @@
isa = XCBuildConfiguration;
baseConfigurationReference = AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
@@ -392,7 +488,7 @@
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.0.5;
MARKETING_VERSION = 1.0.8;
PRODUCT_BUNDLE_IDENTIFIER = russell.sam.forum;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
@@ -401,6 +497,72 @@
};
name = Release;
};
5ADE81402FAD0ACB008DDDE2 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 53DK57C7ZF;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = NotificationService/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = NotificationService;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = russell.sam.forum.nse;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
5ADE81412FAD0ACB008DDDE2 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 53DK57C7ZF;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = NotificationService/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = NotificationService;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = russell.sam.forum.nse;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
@@ -422,6 +584,15 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
5ADE81422FAD0ACB008DDDE2 /* Build configuration list for PBXNativeTarget "NotificationService" */ = {
isa = XCConfigurationList;
buildConfigurations = (
5ADE81402FAD0ACB008DDDE2 /* Debug */,
5ADE81412FAD0ACB008DDDE2 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 504EC2FC1FED79650016851F /* Project object */;

View File

@@ -4,5 +4,7 @@
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.usernotifications.communication</key>
<true/>
</dict>
</plist>

View File

@@ -1,17 +1,17 @@
{
"images" : [
{
"filename" : "Group 74.png",
"filename" : "Screenshot 2026-06-22 at 5.56.53 AM.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "Group 75.png",
"filename" : "Screenshot 2026-06-22 at 5.56.53 AM 1.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "Group 76.png",
"filename" : "Screenshot 2026-06-22 at 5.56.53 AM 2.png",
"idiom" : "universal",
"scale" : "3x"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

View File

@@ -29,6 +29,16 @@
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>Used to find keep local information relevant.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Used to find forums and communities near you</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Access your photos to set a profile picture and share with others</string>
<key>NSUserActivityTypes</key>
<array>
<string>INSendMessageIntent</string>
</array>
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
@@ -41,6 +51,8 @@
<array>
<string>armv7</string>
</array>
<key>UIRequiresFullScreen</key>
<true/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
@@ -49,15 +61,7 @@
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
<key>UIRequiresFullScreen</key>
<true/>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>NSPhotoLibraryUsageDescription</key>
<string>Access your photos to set a profile picture and share with others</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>Used to find keep local information relevant.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Used to find forums and communities near you</string>
</dict>
</plist>

View File

@@ -0,0 +1,53 @@
# Communication Notifications (avatar + app-icon badge)
The Notification Service Extension (`NotificationService.swift`) turns incoming
pushes into **communication notifications** on iOS 15+: a large circular avatar
(the sending network's photo) with the app icon badged in the corner.
## How it works
1. The server (`frm.so`, `notifications.js`) sets `mutableContent: 1` and adds to
the push payload:
- `imageUrl` — a PNG of the network's logo (the server rasterizes SVG logos to
PNG via `sharp`, since iOS can't render SVG avatars).
- `senderName` — the network name (used as the conversation title; falls back
to the notification's own title).
- `isGroup` — `true`, so iOS uses the group-avatar style.
2. The extension downloads `imageUrl`, builds an `INSendMessageIntent` with the
image as the sender/group avatar, donates it, and returns
`content.updating(from: intent)`.
3. On iOS 14 the extension falls back to attaching the image as a plain thumbnail.
## Required setup (both pieces are mandatory)
Two things must be in place or the avatar silently won't render:
### 1. `NSUserActivityTypes` declares the intent (already in this repo)
`INSendMessageIntent` is declared under `NSUserActivityTypes` in **both**
`App/Info.plist` and `NotificationService/Info.plist`. Without this the donation
fails with *"Donating intent 'INSendMessageIntent' is not supported by this
extension"* and the communication styling never applies.
### 2. Communication Notifications capability (one-time, in Xcode — cannot be scripted)
The capability must be registered on the provisioning profile, not just present
in the entitlements file:
1. Open `ios/App/App.xcworkspace` in Xcode.
2. Select the **App** target → **Signing & Capabilities** → **+ Capability** →
add **Communication Notifications**.
- This adds `com.apple.developer.usernotifications.communication` to
`App.entitlements` (already in this repo) and, crucially, enables the
capability on the App ID / provisioning profile. Requires a paid Apple
Developer account.
3. If the avatar still doesn't appear, add the same capability to the
**NotificationService** target as well.
4. Rebuild and reinstall on a device (communication notifications don't reliably
render on the Simulator).
## Notes
- Requires iOS 15.0+ for the communication style; older versions get the plain
attachment fallback automatically.
- The app's deployment target is currently 14.0, so no bump is required.

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.usernotifications.service</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).NotificationService</string>
</dict>
<key>NSUserActivityTypes</key>
<array>
<string>INSendMessageIntent</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,120 @@
//
// NotificationService.swift
// NotificationService
//
// Created by Matias Carulli on 5/7/26.
//
import UserNotifications
import Intents
class NotificationService: UNNotificationServiceExtension {
var contentHandler: ((UNNotificationContent) -> Void)?
var bestAttemptContent: UNMutableNotificationContent?
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
self.contentHandler = contentHandler
bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)
guard
let content = bestAttemptContent,
let imageUrlString = request.content.userInfo["imageUrl"] as? String,
let imageUrl = URL(string: imageUrlString)
else {
contentHandler(request.content)
return
}
let userInfo = request.content.userInfo
// Download the avatar (the sending network's photo), then render the
// notification. A data task lets us hand the bytes straight to INImage.
URLSession.shared.dataTask(with: imageUrl) { data, _, error in
guard let data = data, !data.isEmpty, error == nil else {
contentHandler(content)
return
}
// iOS 15+: render as a communication notification — large circular
// avatar with the app icon badged in the corner.
if #available(iOS 15.0, *) {
self.deliverAsCommunicationNotification(content: content, imageData: data, userInfo: userInfo, contentHandler: contentHandler)
} else {
// iOS 14 fallback: attach the image as a plain thumbnail.
let tmpFile = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("avatar.png")
try? data.write(to: tmpFile)
if let attachment = try? UNNotificationAttachment(identifier: "avatar", url: tmpFile) {
content.attachments = [attachment]
}
contentHandler(content)
}
}.resume()
}
@available(iOS 15.0, *)
private func deliverAsCommunicationNotification(
content: UNMutableNotificationContent,
imageData: Data,
userInfo: [AnyHashable: Any],
contentHandler: @escaping (UNNotificationContent) -> Void
) {
// The conversation title shown in bold. Defaults to the notification's own
// title so existing copy (e.g. "Group | Alice") is preserved.
let senderName = (userInfo["senderName"] as? String) ?? content.title
let isGroup = (userInfo["isGroup"] as? Bool) ?? false
// Group related notifications into one conversation so iOS stacks them.
let conversationId = (userInfo["conversationId"] as? String)
?? (userInfo["threadId"] as? String)
?? content.threadIdentifier
let avatar = INImage(imageData: imageData)
let handle = INPersonHandle(value: conversationId, type: .unknown)
let sender = INPerson(
personHandle: handle,
nameComponents: nil,
displayName: senderName,
image: avatar,
contactIdentifier: nil,
customIdentifier: conversationId
)
let intent = INSendMessageIntent(
recipients: nil,
outgoingMessageType: .outgoingMessageText,
content: content.body,
speakableGroupName: isGroup ? INSpeakableString(spokenPhrase: senderName) : nil,
conversationIdentifier: conversationId,
serviceName: nil,
sender: sender,
attachments: nil
)
// For a group conversation the avatar comes from the group image; for a
// 1:1 it comes from the sender's INPerson image (set above).
if isGroup {
intent.setImage(avatar, forParameterNamed: \.speakableGroupName)
}
let interaction = INInteraction(intent: intent, response: nil)
interaction.direction = .incoming
interaction.donate(completion: nil)
do {
let updated = try content.updating(from: intent)
contentHandler(updated)
} catch {
// If the intent can't be applied, fall back to the unmodified content
// so the notification still shows.
contentHandler(content)
}
}
override func serviceExtensionTimeWillExpire() {
if let contentHandler = contentHandler, let content = bestAttemptContent {
contentHandler(content)
}
}
}

View File

@@ -12,12 +12,16 @@ def capacitor_pods
pod 'Capacitor', :path => '../../node_modules/@capacitor/ios'
pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios'
pod 'CapacitorCamera', :path => '../../node_modules/@capacitor/camera'
pod 'CapacitorFilesystem', :path => '../../node_modules/@capacitor/filesystem'
pod 'CapacitorGeolocation', :path => '../../node_modules/@capacitor/geolocation'
pod 'CapacitorGoogleMaps', :path => '../../node_modules/@capacitor/google-maps'
pod 'CapacitorHaptics', :path => '../../node_modules/@capacitor/haptics'
pod 'CapacitorPreferences', :path => '../../node_modules/@capacitor/preferences'
pod 'CapacitorPushNotifications', :path => '../../node_modules/@capacitor/push-notifications'
pod 'CapacitorSplashScreen', :path => '../../node_modules/@capacitor/splash-screen'
pod 'ForumCapacitorNativeInput', :path => '../../native-input'
pod 'ForumCapacitorNativeSidebar', :path => '../../native-sidebar'
pod 'NotnotsamuelCapacitorSwipeBack', :path => '../../node_modules/@notnotsamuel/capacitor-swipe-back'
end
target 'App' do

View File

@@ -1,23 +1,30 @@
PODS:
- Capacitor (7.4.4):
- Capacitor (7.6.6):
- CapacitorCordova
- CapacitorCamera (7.0.2):
- CapacitorCamera (7.0.5):
- Capacitor
- CapacitorCordova (7.4.4)
- CapacitorGeolocation (7.1.5):
- CapacitorCordova (7.6.6)
- CapacitorFilesystem (7.1.8):
- Capacitor
- IONFilesystemLib (~> 1.1.1)
- CapacitorGeolocation (7.1.8):
- Capacitor
- IONGeolocationLib (= 1.0.1)
- CapacitorGoogleMaps (7.2.0):
- CapacitorGoogleMaps (7.2.2):
- Capacitor
- Google-Maps-iOS-Utils (~> 5.0)
- GoogleMaps (~> 8.4)
- CapacitorHaptics (7.0.3):
- CapacitorHaptics (7.0.5):
- Capacitor
- CapacitorPreferences (7.0.4):
- Capacitor
- CapacitorPushNotifications (7.0.6):
- Capacitor
- CapacitorSplashScreen (7.0.3):
- CapacitorSplashScreen (7.0.5):
- Capacitor
- ForumCapacitorNativeInput (1.0.0):
- Capacitor
- ForumCapacitorNativeSidebar (1.0.0):
- Capacitor
- Google-Maps-iOS-Utils (5.0.0):
- GoogleMaps (~> 8.0)
@@ -26,23 +33,31 @@ PODS:
- GoogleMaps/Base (8.4.0)
- GoogleMaps/Maps (8.4.0):
- GoogleMaps/Base
- IONFilesystemLib (1.1.2)
- IONGeolocationLib (1.0.1)
- NotnotsamuelCapacitorSwipeBack (3.0.2):
- Capacitor
DEPENDENCIES:
- "Capacitor (from `../../node_modules/@capacitor/ios`)"
- "CapacitorCamera (from `../../node_modules/@capacitor/camera`)"
- "CapacitorCordova (from `../../node_modules/@capacitor/ios`)"
- "CapacitorFilesystem (from `../../node_modules/@capacitor/filesystem`)"
- "CapacitorGeolocation (from `../../node_modules/@capacitor/geolocation`)"
- "CapacitorGoogleMaps (from `../../node_modules/@capacitor/google-maps`)"
- "CapacitorHaptics (from `../../node_modules/@capacitor/haptics`)"
- "CapacitorPreferences (from `../../node_modules/@capacitor/preferences`)"
- "CapacitorPushNotifications (from `../../node_modules/@capacitor/push-notifications`)"
- "CapacitorSplashScreen (from `../../node_modules/@capacitor/splash-screen`)"
- ForumCapacitorNativeInput (from `../../native-input`)
- ForumCapacitorNativeSidebar (from `../../native-sidebar`)
- "NotnotsamuelCapacitorSwipeBack (from `../../node_modules/@notnotsamuel/capacitor-swipe-back`)"
SPEC REPOS:
trunk:
- Google-Maps-iOS-Utils
- GoogleMaps
- IONFilesystemLib
- IONGeolocationLib
EXTERNAL SOURCES:
@@ -52,6 +67,8 @@ EXTERNAL SOURCES:
:path: "../../node_modules/@capacitor/camera"
CapacitorCordova:
:path: "../../node_modules/@capacitor/ios"
CapacitorFilesystem:
:path: "../../node_modules/@capacitor/filesystem"
CapacitorGeolocation:
:path: "../../node_modules/@capacitor/geolocation"
CapacitorGoogleMaps:
@@ -64,21 +81,32 @@ EXTERNAL SOURCES:
:path: "../../node_modules/@capacitor/push-notifications"
CapacitorSplashScreen:
:path: "../../node_modules/@capacitor/splash-screen"
ForumCapacitorNativeInput:
:path: "../../native-input"
ForumCapacitorNativeSidebar:
:path: "../../native-sidebar"
NotnotsamuelCapacitorSwipeBack:
:path: "../../node_modules/@notnotsamuel/capacitor-swipe-back"
SPEC CHECKSUMS:
Capacitor: 09d9ff8e9618e8c4b3cab2bbee34a17215dd2fef
CapacitorCamera: 6e18d54c8ab30d7dc7b8cd93d96f9b4f57e9202a
CapacitorCordova: bf648a636f3c153f652d312ae145fb508b6ffced
CapacitorGeolocation: b96474c3259dd4a294227ea8ec19140b1837cceb
CapacitorGoogleMaps: 20b5445a532f80dbb120fa99941fd094bcc88af6
CapacitorHaptics: d17da7dd984cae34111b3f097ccd3e21f9feec62
CapacitorPreferences: d82a7e3b95fcab43a553268b803356522910d153
CapacitorPushNotifications: c6158ba6f3777f281a675aa43e4011e9723e822b
CapacitorSplashScreen: d06ae8804808e9f649a08e7bb7f283c77b688084
Capacitor: 961efd309ab0e390a8081b63c561186f61a6714d
CapacitorCamera: 9e952270be355797f769aa835bb7643a96c871fe
CapacitorCordova: 3a4e0027a89a59421c918d19aee9d6ba1c7a3556
CapacitorFilesystem: c63fc54df41e5a6761785a7f3c49dc696c22e296
CapacitorGeolocation: ab54d708e2fcf99a7bafe0219eaa6f7a932f001a
CapacitorGoogleMaps: e3a1c0b501daa84a327b6ee807ee515a7f890403
CapacitorHaptics: 1b145fa83c622e5383a3bac04ea81e2514f0b0f5
CapacitorPreferences: 69d9991307507aeab8ef8019c10b9babfda0e9ca
CapacitorPushNotifications: b8e30637549ce91468b8c4dddbef2657be831f4c
CapacitorSplashScreen: 409eeaaeac603d264f209c0fcb718aa22fe1f7bb
ForumCapacitorNativeInput: 0f83e3b9755ed05fccc3eac1daaf9bf5ecf10f2b
ForumCapacitorNativeSidebar: 2b93b6d0c8ae25a08437fe98bccfc528d8f9626a
Google-Maps-iOS-Utils: 66d6de12be1ce6d3742a54661e7a79cb317a9321
GoogleMaps: 8939898920281c649150e0af74aa291c60f2e77d
IONFilesystemLib: 21a63377696b2d8fab5632ecfb7d2ac67bddb68a
IONGeolocationLib: 20f9d0248a0b5264511fb57a37e25dd2badf797a
NotnotsamuelCapacitorSwipeBack: b3a00145209b368cdfe21816d7919484c3fe654e
PODFILE CHECKSUM: 7fad0e16088b635c7bc1980fea245d4a60cc4bbe
PODFILE CHECKSUM: 5005cc1c35ad0396f6966ccf4d0c4d4790dac751
COCOAPODS: 1.15.2
COCOAPODS: 1.16.2

View File

@@ -0,0 +1,17 @@
require 'json'
package = JSON.parse(File.read(File.join(__dir__, 'package.json')))
Pod::Spec.new do |s|
s.name = 'ForumCapacitorNativeInput'
s.version = package['version']
s.summary = package['description']
s.license = package['license']
s.homepage = 'https://frm.so'
s.author = package['author']
s.source = { :git => 'https://frm.so', :tag => s.version.to_s }
s.source_files = 'ios/Plugin/**/*.{swift,h,m,c,cc,mm,cpp}'
s.ios.deployment_target = '14.0'
s.dependency 'Capacitor'
s.swift_version = '5.1'
end

65
native-input/dist/esm/definitions.d.ts vendored Normal file
View File

@@ -0,0 +1,65 @@
import type { PluginListenerHandle } from '@capacitor/core';
/**
* Colors are CSS color strings resolved on the web side from the chat theme
* variables, so the native bar matches light/dark mode and the in-app palette.
*/
export interface NativeInputTheme {
/** Bar / text field background. From `--chat-input-bg`. */
inputBackground?: string;
/** Text + caret color. From `--chat-input-text`. */
text?: string;
/** Placeholder color. */
placeholder?: string;
/** Accent used for the "+" button. From `--accent`. */
accent?: string;
/** Send button background. From `--chat-sent-bg`. */
sendBackground?: string;
/** Send button glyph color. From `--chat-sent-text`. */
sendText?: string;
/** Whether the app is currently in dark mode (drives the glass/blur tint). */
dark?: boolean;
}
export interface PresentOptions {
/** Identifies the active chat so re-presents for a different chat reset text. */
chatId: string;
placeholder?: string;
/** Pre-fill text (e.g. a half-typed draft when re-mounting). */
text?: string;
/** When false, the field is read-only and the send button is disabled. */
canSend?: boolean;
theme?: NativeInputTheme;
}
export interface NativeInputPlugin {
/** Create + show the native input bar pinned above the keyboard. */
present(options: PresentOptions): Promise<void>;
/** Remove the native input bar. */
dismiss(): Promise<void>;
setText(options: { text: string }): Promise<void>;
getText(): Promise<{ text: string }>;
focus(): Promise<void>;
blur(): Promise<void>;
setPlaceholder(options: { placeholder: string }): Promise<void>;
setEnabled(options: { enabled: boolean }): Promise<void>;
setTheme(options: { theme: NativeInputTheme }): Promise<void>;
/** Hide/show without tearing down (e.g. when navigating away briefly). */
setVisible(options: { visible: boolean }): Promise<void>;
/** Fires on every text edit. */
addListener(eventName: 'inputChange', listenerFunc: (data: { text: string }) => void): Promise<PluginListenerHandle>;
/** Fires when the user taps send / hits the send key. */
addListener(eventName: 'send', listenerFunc: (data: { text: string }) => void): Promise<PluginListenerHandle>;
/** Fires when the "+" attach button is tapped. */
addListener(eventName: 'attach', listenerFunc: () => void): Promise<PluginListenerHandle>;
/** Fires on focus / blur of the native field. */
addListener(eventName: 'focusChange', listenerFunc: (data: { focused: boolean }) => void): Promise<PluginListenerHandle>;
/**
* Fires when the space the bar (and keyboard) occupies changes, in CSS px,
* so the web layout can pad its scroll area / position the reply strip.
*/
addListener(eventName: 'heightChange', listenerFunc: (data: { bottomInset: number }) => void): Promise<PluginListenerHandle>;
removeAllListeners(): Promise<void>;
}

1
native-input/dist/esm/definitions.js vendored Normal file
View File

@@ -0,0 +1 @@
export {};

4
native-input/dist/esm/index.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
import type { NativeInputPlugin } from './definitions';
declare const NativeInput: NativeInputPlugin;
export * from './definitions';
export { NativeInput };

8
native-input/dist/esm/index.js vendored Normal file
View File

@@ -0,0 +1,8 @@
import { registerPlugin } from '@capacitor/core';
const NativeInput = registerPlugin('NativeInput', {
web: () => import('./web').then(m => new m.NativeInputWeb()),
});
export * from './definitions';
export { NativeInput };

15
native-input/dist/esm/web.d.ts vendored Normal file
View File

@@ -0,0 +1,15 @@
import { WebPlugin } from '@capacitor/core';
import type { NativeInputPlugin } from './definitions';
export declare class NativeInputWeb extends WebPlugin implements NativeInputPlugin {
present(): Promise<void>;
dismiss(): Promise<void>;
setText(): Promise<void>;
getText(): Promise<{ text: string }>;
focus(): Promise<void>;
blur(): Promise<void>;
setPlaceholder(): Promise<void>;
setEnabled(): Promise<void>;
setTheme(): Promise<void>;
setVisible(): Promise<void>;
addListener(eventName: any, listenerFunc: any): Promise<any>;
}

17
native-input/dist/esm/web.js vendored Normal file
View File

@@ -0,0 +1,17 @@
import { WebPlugin } from '@capacitor/core';
// No-op web implementation so the plugin import resolves in the browser build.
// The HTML composer (textarea) is used on web; these methods are never the
// active path there, but must exist to avoid runtime crashes.
export class NativeInputWeb extends WebPlugin {
async present() { /* no-op on web */ }
async dismiss() { /* no-op on web */ }
async setText() { /* no-op on web */ }
async getText() { return { text: '' }; }
async focus() { /* no-op on web */ }
async blur() { /* no-op on web */ }
async setPlaceholder() { /* no-op on web */ }
async setEnabled() { /* no-op on web */ }
async setTheme() { /* no-op on web */ }
async setVisible() { /* no-op on web */ }
}

25
native-input/dist/plugin.cjs.js vendored Normal file
View File

@@ -0,0 +1,25 @@
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
const core = require('@capacitor/core');
class NativeInputWeb extends core.WebPlugin {
async present() {}
async dismiss() {}
async setText() {}
async getText() { return { text: '' }; }
async focus() {}
async blur() {}
async setPlaceholder() {}
async setEnabled() {}
async setTheme() {}
async setVisible() {}
}
const NativeInput = core.registerPlugin('NativeInput', {
web: () => Promise.resolve(new NativeInputWeb()),
});
exports.NativeInput = NativeInput;
exports.NativeInputWeb = NativeInputWeb;

View File

@@ -0,0 +1,493 @@
import UIKit
/// A floating chat input bar (Liquid Glass on iOS 26+, blur material fallback)
/// that pins itself above the keyboard and reports the bottom inset it occupies
/// so the web layout can pad its scroll area accordingly.
class ChatInputBar: UIView, UITextViewDelegate {
// MARK: Callbacks to the plugin
var onTextChange: ((String) -> Void)?
var onSend: ((String) -> Void)?
var onAttach: (() -> Void)?
var onFocusChange: ((Bool) -> Void)?
/// Reports the bottom inset (in points == CSS px for this viewport) that the
/// bar + keyboard occupy at the bottom of the host view.
var onInsetChange: ((CGFloat) -> Void)?
// MARK: Tunables
private let minTextHeight: CGFloat = 45 // ~25% taller than the old 36
private let maxTextHeight: CGFloat = 150
private let fieldVerticalPadding: CGFloat = 8
private let barVerticalPadding: CGFloat = 8
// MARK: Views
// Hosts the controls in a scroll view with `.interactive` keyboard dismiss
// so dragging the bar down lets the keyboard follow the finger.
private let panScroll = UIScrollView()
private let contentView = UIView()
private let field = UIView()
private let fieldEffect = UIVisualEffectView(effect: ChatInputBar.makeEffect())
private let plusEffect = UIVisualEffectView(effect: ChatInputBar.makeEffect())
private let textView = UITextView()
private let placeholderLabel = UILabel()
private let plusButton = UIButton(type: .system)
private let sendButton = UIButton(type: .system)
private weak var hostView: UIView?
private var textHeightConstraint: NSLayoutConstraint!
// Pins the *visible* bar (the field) above the keyboard. The bar view itself
// spans the whole host so its scroll view can drive interactive dismissal.
private var fieldBottomConstraint: NSLayoutConstraint!
private var canSend = true
/// True while the keyboard is up — used to widen the bar's touch-catch area
/// (see hitTest) only when there's actually a keyboard to dismiss.
private var keyboardUp = false
/// True when the running OS gave us a real, interactive `UIGlassEffect`
/// (so the glass surfaces animate themselves on press). When false we fall
/// back to a manual press animation on the buttons.
private static var supportsInteractiveGlass = false
// MARK: Lifecycle
init(hostView: UIView) {
self.hostView = hostView
super.init(frame: .zero)
setupViews()
}
required init?(coder: NSCoder) { fatalError("init(coder:) not supported") }
private static func makeEffect() -> UIVisualEffect {
// Use real Liquid Glass on iOS 26+. Resolved dynamically via the
// Objective-C runtime so the plugin still compiles against older SDKs
// (where the UIGlassEffect symbol does not yet exist).
if #available(iOS 26.0, *),
let glassClass = NSClassFromString("UIGlassEffect") as? NSObject.Type {
let glass = glassClass.init()
// Opt into the system's built-in touch animation (the scale + tint
// "squish" on press). Set via KVC behind a responds(to:) guard so it
// compiles against SDKs that lack UIGlassEffect and never crashes if
// the property is renamed/absent — it just stays static.
let setter = Selector("setInteractive:")
if glass.responds(to: setter) {
glass.setValue(true, forKey: "interactive")
supportsInteractiveGlass = true
}
if let effect = glass as? UIVisualEffect { return effect }
}
return UIBlurEffect(style: .systemUltraThinMaterial)
}
/// Adds the bar to the host view. The bar view fills the whole host (so its
/// scroll view's bounds overlap the keyboard and can drive interactive
/// dismissal); only the visible field/"+" capture touches (see hitTest), and
/// the visible field is pinned just above the keyboard.
func attach() {
guard let host = hostView else { return }
translatesAutoresizingMaskIntoConstraints = false
host.addSubview(self)
NSLayoutConstraint.activate([
leadingAnchor.constraint(equalTo: host.leadingAnchor),
trailingAnchor.constraint(equalTo: host.trailingAnchor),
topAnchor.constraint(equalTo: host.topAnchor),
bottomAnchor.constraint(equalTo: host.bottomAnchor),
])
if #available(iOS 15.0, *) {
// keyboardLayoutGuide rides the keyboard and rests at the safe-area
// bottom when the keyboard is dismissed.
host.keyboardLayoutGuide.followsUndockedKeyboard = true
fieldBottomConstraint = field.bottomAnchor.constraint(
equalTo: host.keyboardLayoutGuide.topAnchor, constant: -barVerticalPadding)
} else {
fieldBottomConstraint = field.bottomAnchor.constraint(
equalTo: host.safeAreaLayoutGuide.bottomAnchor, constant: -barVerticalPadding)
}
fieldBottomConstraint.isActive = true
observeKeyboard()
host.layoutIfNeeded() // so the field has a real frame before measuring
reportInset()
}
func removeFromHost() {
NotificationCenter.default.removeObserver(self)
removeFromSuperview()
onInsetChange?(0)
}
/// The bar view spans the entire host so its scroll view can drive
/// interactive keyboard dismissal, but only the visible field/"+" should
/// swallow touches — every other point falls through to the web view beneath.
/// (A touch that begins on the bar is still tracked by the scroll view's pan
/// as the finger then drags down across the rest of the screen.)
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
guard let hit = super.hitTest(point, with: event) else { return nil }
// Always let the visible controls handle their own touches.
if hit == field || hit == plusEffect
|| hit.isDescendant(of: field) || hit.isDescendant(of: plusEffect) {
return hit
}
// While the keyboard is up, also claim an invisible strip ~1.5× the input
// height above the field (and everything below it) so a downward drag
// begun just above the bar still drives the interactive dismissal. Above
// the strip, fall through to the web view.
if keyboardUp {
let fieldInSelf = field.convert(field.bounds, to: self)
let catchTop = fieldInSelf.minY - 1.5 * minTextHeight
if point.y >= catchTop { return panScroll }
}
return nil
}
// MARK: Setup
private func setupViews() {
// The bar itself is transparent so only the field + "+" button float.
backgroundColor = .clear
// Scroll view that drives the native interactive keyboard dismissal.
// It fills the whole host so its bounds overlap the keyboard region: a
// downward drag that begins on the bar stays inside the scroll view as it
// travels toward the keyboard, which is what `.interactive` needs to track
// the finger. It never really scrolls (content == frame) but
// `alwaysBounceVertical` keeps its pan recognizer live.
panScroll.translatesAutoresizingMaskIntoConstraints = false
panScroll.keyboardDismissMode = .interactive
panScroll.alwaysBounceVertical = true
panScroll.showsVerticalScrollIndicator = false
panScroll.showsHorizontalScrollIndicator = false
panScroll.delaysContentTouches = false
panScroll.backgroundColor = .clear
if #available(iOS 11.0, *) { panScroll.contentInsetAdjustmentBehavior = .never }
addSubview(panScroll)
contentView.translatesAutoresizingMaskIntoConstraints = false
contentView.backgroundColor = .clear
panScroll.addSubview(contentView)
// Circular "+" attach button — same glass + border as the input field.
// Interaction is enabled so the interactive glass effect can observe
// touches and run its built-in press animation.
plusEffect.translatesAutoresizingMaskIntoConstraints = false
plusEffect.isUserInteractionEnabled = true
plusEffect.layer.cornerRadius = minTextHeight / 2
plusEffect.clipsToBounds = true
plusEffect.layer.borderWidth = 1
plusEffect.layer.borderColor = UIColor.separator.cgColor
contentView.addSubview(plusEffect)
// The button lives inside the effect's contentView so its touches are
// delivered through the glass (required for the interactive animation).
plusButton.translatesAutoresizingMaskIntoConstraints = false
plusButton.setImage(UIImage(systemName: "plus"), for: .normal)
plusButton.addTarget(self, action: #selector(attachTapped), for: .touchUpInside)
plusEffect.contentView.addSubview(plusButton)
addPressAnimation(to: plusButton, enabled: !ChatInputBar.supportsInteractiveGlass)
// Floating rounded input field.
field.translatesAutoresizingMaskIntoConstraints = false
field.layer.cornerRadius = 18
field.layer.borderWidth = 1
field.layer.borderColor = UIColor.separator.cgColor
field.clipsToBounds = true
contentView.addSubview(field)
// Glass / blur background fills the field (behind the content).
// Interaction is enabled so tapping the input runs the glass's built-in
// press animation; the text view and placeholder live in its contentView.
fieldEffect.translatesAutoresizingMaskIntoConstraints = false
fieldEffect.isUserInteractionEnabled = true
fieldEffect.layer.cornerRadius = 18
fieldEffect.clipsToBounds = true
field.addSubview(fieldEffect)
// Growing text view.
textView.translatesAutoresizingMaskIntoConstraints = false
textView.backgroundColor = .clear
textView.font = .systemFont(ofSize: 17)
textView.isScrollEnabled = false
textView.textContainerInset = UIEdgeInsets(top: 11, left: 6, bottom: 11, right: 6)
textView.delegate = self
fieldEffect.contentView.addSubview(textView)
// Placeholder overlay.
placeholderLabel.translatesAutoresizingMaskIntoConstraints = false
placeholderLabel.font = .systemFont(ofSize: 17)
placeholderLabel.textColor = UIColor.placeholderText
placeholderLabel.isUserInteractionEnabled = false
fieldEffect.contentView.addSubview(placeholderLabel)
// Send button — a solid (non-glass) button overlaid inside the field,
// bottom-right. Since it is not a glass surface it gets a manual press
// animation so it still scales/dims like the glass controls.
sendButton.translatesAutoresizingMaskIntoConstraints = false
sendButton.setImage(UIImage(systemName: "arrow.up"), for: .normal)
sendButton.addTarget(self, action: #selector(sendTapped), for: .touchUpInside)
sendButton.layer.cornerRadius = 18
sendButton.clipsToBounds = true
field.addSubview(sendButton)
addPressAnimation(to: sendButton, enabled: true)
textHeightConstraint = textView.heightAnchor.constraint(equalToConstant: minTextHeight)
NSLayoutConstraint.activate([
// Scroll view fills the bar; content view matches its frame so it
// never scrolls but its pan stays active for interactive dismiss.
panScroll.topAnchor.constraint(equalTo: topAnchor),
panScroll.leadingAnchor.constraint(equalTo: leadingAnchor),
panScroll.trailingAnchor.constraint(equalTo: trailingAnchor),
panScroll.bottomAnchor.constraint(equalTo: bottomAnchor),
contentView.topAnchor.constraint(equalTo: panScroll.contentLayoutGuide.topAnchor),
contentView.leadingAnchor.constraint(equalTo: panScroll.contentLayoutGuide.leadingAnchor),
contentView.trailingAnchor.constraint(equalTo: panScroll.contentLayoutGuide.trailingAnchor),
contentView.bottomAnchor.constraint(equalTo: panScroll.contentLayoutGuide.bottomAnchor),
contentView.widthAnchor.constraint(equalTo: panScroll.frameLayoutGuide.widthAnchor),
contentView.heightAnchor.constraint(equalTo: panScroll.frameLayoutGuide.heightAnchor),
// Circular "+" button sized to the input's resting height, aligned
// to the field's bottom.
plusEffect.widthAnchor.constraint(equalToConstant: minTextHeight),
plusEffect.heightAnchor.constraint(equalToConstant: minTextHeight),
plusEffect.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 12),
plusEffect.bottomAnchor.constraint(equalTo: field.bottomAnchor),
plusButton.leadingAnchor.constraint(equalTo: plusEffect.leadingAnchor),
plusButton.trailingAnchor.constraint(equalTo: plusEffect.trailingAnchor),
plusButton.topAnchor.constraint(equalTo: plusEffect.topAnchor),
plusButton.bottomAnchor.constraint(equalTo: plusEffect.bottomAnchor),
field.leadingAnchor.constraint(equalTo: plusEffect.trailingAnchor, constant: 8),
field.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -12),
// field.bottom is pinned to the keyboard guide in attach(); its top is
// driven by the text view's height. Its vertical position is therefore
// independent of the (full-host) content view.
fieldEffect.topAnchor.constraint(equalTo: field.topAnchor),
fieldEffect.leadingAnchor.constraint(equalTo: field.leadingAnchor),
fieldEffect.trailingAnchor.constraint(equalTo: field.trailingAnchor),
fieldEffect.bottomAnchor.constraint(equalTo: field.bottomAnchor),
// Send button overlaid inside the field, bottom-right.
sendButton.widthAnchor.constraint(equalToConstant: 36),
sendButton.heightAnchor.constraint(equalToConstant: 36),
sendButton.trailingAnchor.constraint(equalTo: field.trailingAnchor, constant: -4),
sendButton.bottomAnchor.constraint(equalTo: field.bottomAnchor, constant: -4),
textView.leadingAnchor.constraint(equalTo: field.leadingAnchor, constant: 6),
textView.trailingAnchor.constraint(equalTo: sendButton.leadingAnchor, constant: -4),
textView.topAnchor.constraint(equalTo: field.topAnchor),
textView.bottomAnchor.constraint(equalTo: field.bottomAnchor),
textHeightConstraint,
placeholderLabel.leadingAnchor.constraint(equalTo: textView.leadingAnchor, constant: 11),
placeholderLabel.centerYAnchor.constraint(equalTo: textView.centerYAnchor),
])
sendButton.backgroundColor = tintColor
updateTextHeight()
}
// MARK: Public API
var text: String {
get { textView.text ?? "" }
set {
textView.text = newValue
textChanged(notify: false)
}
}
func setPlaceholder(_ value: String) {
placeholderLabel.text = value
}
func setEnabled(_ enabled: Bool) {
canSend = enabled
textView.isEditable = enabled
sendButton.isEnabled = enabled
plusButton.isEnabled = enabled
alpha = enabled ? 1.0 : 0.6
}
func focus() { textView.becomeFirstResponder() }
func blur() { textView.resignFirstResponder() }
func setVisible(_ visible: Bool) {
isHidden = !visible
if !visible { onInsetChange?(0) } else { reportInset() }
}
// MARK: Theme
func applyTheme(_ theme: [String: Any]) {
// The field/"+" keep a clear background so the glass material shows
// through and they read as floating; only content colors are themed.
if let txt = (theme["text"] as? String).flatMap(UIColor.fromCSS) {
textView.textColor = txt
textView.tintColor = txt
}
if let ph = (theme["placeholder"] as? String).flatMap(UIColor.fromCSS) {
placeholderLabel.textColor = ph
}
if let accent = (theme["accent"] as? String).flatMap(UIColor.fromCSS) {
plusButton.tintColor = accent
}
if let sendBg = (theme["sendBackground"] as? String).flatMap(UIColor.fromCSS) {
sendButton.backgroundColor = sendBg
}
if let sendTxt = (theme["sendText"] as? String).flatMap(UIColor.fromCSS) {
sendButton.tintColor = sendTxt
}
if let dark = theme["dark"] as? Bool {
overrideUserInterfaceStyle = dark ? .dark : .light
}
}
// MARK: Actions
@objc private func attachTapped() { onAttach?() }
@objc private func sendTapped() {
guard canSend else { return }
onSend?(textView.text ?? "")
}
// MARK: Press animation
// Manual scale + dim used for the solid send button, and for the "+" when
// the OS doesn't provide interactive glass (so it still feels pressable).
private func addPressAnimation(to button: UIButton, enabled: Bool) {
guard enabled else { return }
button.addTarget(self, action: #selector(pressDown(_:)),
for: [.touchDown, .touchDragEnter])
button.addTarget(self, action: #selector(pressUp(_:)),
for: [.touchUpInside, .touchUpOutside, .touchCancel, .touchDragExit])
}
@objc private func pressDown(_ button: UIButton) {
UIView.animate(withDuration: 0.12, delay: 0,
options: [.allowUserInteraction, .beginFromCurrentState]) {
button.transform = CGAffineTransform(scaleX: 0.88, y: 0.88)
button.alpha = 0.7
}
}
@objc private func pressUp(_ button: UIButton) {
UIView.animate(withDuration: 0.28, delay: 0,
usingSpringWithDamping: 0.5, initialSpringVelocity: 0.6,
options: [.allowUserInteraction, .beginFromCurrentState]) {
button.transform = .identity
button.alpha = 1.0
}
}
// MARK: UITextViewDelegate
func textViewDidChange(_ textView: UITextView) {
textChanged(notify: true)
}
func textViewDidBeginEditing(_ textView: UITextView) { onFocusChange?(true) }
func textViewDidEndEditing(_ textView: UITextView) { onFocusChange?(false) }
private func textChanged(notify: Bool) {
placeholderLabel.isHidden = !(textView.text ?? "").isEmpty
updateTextHeight()
if notify { onTextChange?(textView.text ?? "") }
}
private func updateTextHeight() {
let fitting = textView.sizeThatFits(CGSize(width: textView.bounds.width, height: .greatestFiniteMagnitude))
let clamped = min(max(fitting.height, minTextHeight), maxTextHeight)
if abs(textHeightConstraint.constant - clamped) > 0.5 {
textHeightConstraint.constant = clamped
textView.isScrollEnabled = fitting.height > maxTextHeight
superview?.layoutIfNeeded()
DispatchQueue.main.async { [weak self] in self?.reportInset() }
}
}
// MARK: Inset reporting
private func observeKeyboard() {
// Observe the *will* notifications — they fire at the START of the
// keyboard animation with the final frame, so the web layout can move in
// step with the keyboard instead of lagging until the animation ends
// (which is what the *did* notifications would do).
let names: [NSNotification.Name] = [
UIResponder.keyboardWillChangeFrameNotification,
UIResponder.keyboardWillShowNotification,
UIResponder.keyboardWillHideNotification,
]
for name in names {
NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillChange(_:)),
name: name, object: nil)
}
}
@objc private func keyboardWillChange(_ note: Notification) {
guard let host = hostView, !isHidden else { return }
guard let end = (note.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue else {
reportInset(); return
}
// Where the bar will rest: just above the keyboard, but never below the
// safe-area bottom (its resting place when the keyboard is dismissed).
let kbTop = host.convert(end, from: nil).minY
let safeBottom = host.bounds.height - host.safeAreaInsets.bottom
let barBottom = min(kbTop, safeBottom)
keyboardUp = barBottom < safeBottom - 0.5
if #available(iOS 15.0, *) {
// keyboardLayoutGuide animates the bar itself; nothing to drive here.
} else {
fieldBottomConstraint.constant = (barBottom - safeBottom) - barVerticalPadding
host.layoutIfNeeded()
}
// Report the *target* inset (computed from the keyboard's end frame, since
// the field hasn't moved yet on a *will* notification) so the web updates
// at the animation's start.
let fieldTop = barBottom - barVerticalPadding - field.bounds.height
let inset = max(0, host.bounds.height - fieldTop + barVerticalPadding)
onInsetChange?(inset)
}
private func reportInset() {
guard let host = hostView, !isHidden else { return }
// The bar view spans the whole host, so measure the *visible* field's top
// (in host coordinates) and report from there down to the host bottom.
let fieldTop = field.convert(field.bounds, to: host).minY
let inset = max(0, host.bounds.height - fieldTop + barVerticalPadding)
onInsetChange?(inset)
}
}
// MARK: - CSS color parsing
extension UIColor {
/// Parses the common CSS color strings that `getComputedStyle` returns
/// (`rgb(...)`, `rgba(...)`) plus `#rgb`/`#rrggbb`.
static func fromCSS(_ string: String) -> UIColor? {
let s = string.trimmingCharacters(in: .whitespaces).lowercased()
if s.hasPrefix("#") {
var hex = String(s.dropFirst())
if hex.count == 3 { hex = hex.map { "\($0)\($0)" }.joined() }
guard hex.count == 6, let v = UInt32(hex, radix: 16) else { return nil }
return UIColor(red: CGFloat((v >> 16) & 0xff) / 255,
green: CGFloat((v >> 8) & 0xff) / 255,
blue: CGFloat(v & 0xff) / 255, alpha: 1)
}
if s.hasPrefix("rgb") {
let inner = s.drop { $0 != "(" }.dropFirst().prefix { $0 != ")" }
let parts = inner.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }
guard parts.count >= 3,
let r = Double(parts[0]), let g = Double(parts[1]), let b = Double(parts[2]) else { return nil }
let a = parts.count >= 4 ? (Double(parts[3]) ?? 1) : 1
return UIColor(red: CGFloat(r) / 255, green: CGFloat(g) / 255, blue: CGFloat(b) / 255, alpha: CGFloat(a))
}
return nil
}
}

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSPrincipalClass</key>
<string></string>
</dict>
</plist>

View File

@@ -0,0 +1,7 @@
#import <UIKit/UIKit.h>
//! Project version number for Plugin.
FOUNDATION_EXPORT double PluginVersionNumber;
//! Project version string for Plugin.
FOUNDATION_EXPORT const unsigned char PluginVersionString[];

View File

@@ -0,0 +1,17 @@
#import <Foundation/Foundation.h>
#import <Capacitor/Capacitor.h>
// Define the plugin using the CAP_PLUGIN Macro, and
// each method the plugin supports using the CAP_PLUGIN_METHOD macro.
CAP_PLUGIN(NativeInputPlugin, "NativeInput",
CAP_PLUGIN_METHOD(present, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(dismiss, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(setText, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(getText, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(focus, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(blur, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(setPlaceholder, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(setEnabled, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(setTheme, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(setVisible, CAPPluginReturnPromise);
)

View File

@@ -0,0 +1,117 @@
import Foundation
import Capacitor
@objc(NativeInputPlugin)
public class NativeInputPlugin: CAPPlugin {
private var bar: ChatInputBar?
private var currentChatId: String?
// MARK: Helpers
private func hostView() -> UIView? {
// Pin the bar as a sibling of the web view inside the same parent so it
// can float above the web content and track the keyboard.
return webView?.superview ?? webView
}
private func ensureBar() -> ChatInputBar? {
if let bar = bar { return bar }
guard let host = hostView() else { return nil }
let bar = ChatInputBar(hostView: host)
bar.onTextChange = { [weak self] text in
self?.notifyListeners("inputChange", data: ["text": text])
}
bar.onSend = { [weak self] text in
self?.notifyListeners("send", data: ["text": text])
}
bar.onAttach = { [weak self] in
self?.notifyListeners("attach", data: [:])
}
bar.onFocusChange = { [weak self] focused in
self?.notifyListeners("focusChange", data: ["focused": focused])
}
bar.onInsetChange = { [weak self] inset in
self?.notifyListeners("heightChange", data: ["bottomInset": Double(inset)])
}
bar.attach()
self.bar = bar
return bar
}
// MARK: Plugin methods
@objc func present(_ call: CAPPluginCall) {
let chatId = call.getString("chatId") ?? ""
let placeholder = call.getString("placeholder") ?? ""
let text = call.getString("text") ?? ""
let canSend = call.getBool("canSend") ?? true
let theme = call.getObject("theme")
DispatchQueue.main.async {
guard let bar = self.ensureBar() else { call.reject("No host view"); return }
bar.setVisible(true)
if let theme = theme { bar.applyTheme(theme) }
bar.setPlaceholder(placeholder)
bar.setEnabled(canSend)
// Only reset text when switching to a different chat; otherwise keep
// whatever the user already had typed in this bar.
if self.currentChatId != chatId {
bar.text = text
self.currentChatId = chatId
}
call.resolve()
}
}
@objc func dismiss(_ call: CAPPluginCall) {
DispatchQueue.main.async {
self.bar?.removeFromHost()
self.bar = nil
self.currentChatId = nil
call.resolve()
}
}
@objc func setText(_ call: CAPPluginCall) {
let text = call.getString("text") ?? ""
DispatchQueue.main.async {
self.bar?.text = text
call.resolve()
}
}
@objc func getText(_ call: CAPPluginCall) {
DispatchQueue.main.async {
call.resolve(["text": self.bar?.text ?? ""])
}
}
@objc func focus(_ call: CAPPluginCall) {
DispatchQueue.main.async { self.bar?.focus(); call.resolve() }
}
@objc func blur(_ call: CAPPluginCall) {
DispatchQueue.main.async { self.bar?.blur(); call.resolve() }
}
@objc func setPlaceholder(_ call: CAPPluginCall) {
let placeholder = call.getString("placeholder") ?? ""
DispatchQueue.main.async { self.bar?.setPlaceholder(placeholder); call.resolve() }
}
@objc func setEnabled(_ call: CAPPluginCall) {
let enabled = call.getBool("enabled") ?? true
DispatchQueue.main.async { self.bar?.setEnabled(enabled); call.resolve() }
}
@objc func setTheme(_ call: CAPPluginCall) {
let theme = call.getObject("theme") ?? [:]
DispatchQueue.main.async { self.bar?.applyTheme(theme); call.resolve() }
}
@objc func setVisible(_ call: CAPPluginCall) {
let visible = call.getBool("visible") ?? true
DispatchQueue.main.async { self.bar?.setVisible(visible); call.resolve() }
}
}

31
native-input/package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "@forum/capacitor-native-input",
"version": "1.0.0",
"description": "Native iOS chat input bar (Liquid Glass) for the Forum app.",
"main": "dist/plugin.cjs.js",
"module": "dist/esm/index.js",
"types": "dist/esm/index.d.ts",
"files": [
"dist/",
"ios/Plugin/",
"ForumCapacitorNativeInput.podspec"
],
"author": "Forum",
"license": "MIT",
"keywords": [
"capacitor",
"plugin",
"native",
"ios",
"keyboard",
"input"
],
"peerDependencies": {
"@capacitor/core": "^7.0.0"
},
"capacitor": {
"ios": {
"src": "ios"
}
}
}

View File

@@ -0,0 +1,17 @@
require 'json'
package = JSON.parse(File.read(File.join(__dir__, 'package.json')))
Pod::Spec.new do |s|
s.name = 'ForumCapacitorNativeSidebar'
s.version = package['version']
s.summary = package['description']
s.license = package['license']
s.homepage = 'https://frm.so'
s.author = package['author']
s.source = { :git => 'https://frm.so', :tag => s.version.to_s }
s.source_files = 'ios/Plugin/**/*.{swift,h,m,c,cc,mm,cpp}'
s.ios.deployment_target = '14.0'
s.dependency 'Capacitor'
s.swift_version = '5.1'
end

View File

@@ -0,0 +1,65 @@
import type { PluginListenerHandle } from '@capacitor/core';
/** A network row rendered in the sidebar. Shapes match `global.networks`. */
export interface SidebarNetwork {
id: number;
name: string;
abbreviation: string;
logo?: string | null;
displayOrder?: number;
data?: {
announcements?: { text: string }[];
members?: { id: number }[];
};
}
export interface SidebarProfile {
id: number;
first_name?: string;
last_name?: string;
email?: string;
image_path?: string | null;
}
export interface SidebarData {
/** Origin that serves the UI (quill.js, Sidebar.js, host page). */
uiOrigin: string;
/** Origin that serves images (logos, avatars). */
serverOrigin: string;
profile: SidebarProfile;
networks: SidebarNetwork[];
currentNetworkId?: number | null;
/** Current theme key: light | parchment | dark | red. */
theme?: string;
/** Whether the current user may create networks. */
canCreateNetwork?: boolean;
}
/** An action forwarded from the sidebar's content web view to the main app. */
export interface SidebarAction {
type: 'selectNetwork' | 'openProfile' | 'createNetwork' | 'setTheme' | 'logout';
abbreviation?: string;
value?: string;
}
export interface NativeSidebarPlugin {
/** Create (pre-warm) the drawer if needed and push the latest data into it. */
setData(data: SidebarData): Promise<void>;
/** Update just the theme (key) without re-sending all data. */
setTheme(options: { theme: string }): Promise<void>;
/** Animate the drawer open. */
open(): Promise<void>;
/** Animate the drawer closed. */
close(): Promise<void>;
/** Enable/disable the edge-drag gesture (disabled on deep routes). */
setEnabled(options: { enabled: boolean }): Promise<void>;
/** Tear down the drawer entirely. */
dismiss(): Promise<void>;
/** Fires when the user taps something in the sidebar content. */
addListener(eventName: 'action', listenerFunc: (data: SidebarAction) => void): Promise<PluginListenerHandle>;
/** Fires when the drawer finishes opening/closing. */
addListener(eventName: 'stateChange', listenerFunc: (data: { open: boolean }) => void): Promise<PluginListenerHandle>;
removeAllListeners(): Promise<void>;
}

View File

@@ -0,0 +1 @@
export {};

4
native-sidebar/dist/esm/index.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
import type { NativeSidebarPlugin } from './definitions';
declare const NativeSidebar: NativeSidebarPlugin;
export * from './definitions';
export { NativeSidebar };

8
native-sidebar/dist/esm/index.js vendored Normal file
View File

@@ -0,0 +1,8 @@
import { registerPlugin } from '@capacitor/core';
const NativeSidebar = registerPlugin('NativeSidebar', {
web: () => import('./web').then(m => new m.NativeSidebarWeb()),
});
export * from './definitions';
export { NativeSidebar };

11
native-sidebar/dist/esm/web.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
import { WebPlugin } from '@capacitor/core';
import type { NativeSidebarPlugin } from './definitions';
export declare class NativeSidebarWeb extends WebPlugin implements NativeSidebarPlugin {
setData(): Promise<void>;
setTheme(): Promise<void>;
open(): Promise<void>;
close(): Promise<void>;
setEnabled(): Promise<void>;
dismiss(): Promise<void>;
addListener(eventName: any, listenerFunc: any): Promise<any>;
}

13
native-sidebar/dist/esm/web.js vendored Normal file
View File

@@ -0,0 +1,13 @@
import { WebPlugin } from '@capacitor/core';
// No-op web implementation so the plugin import resolves in the browser build.
// The drawer is iOS-native only; on web these methods are never the active path
// but must exist to avoid runtime crashes.
export class NativeSidebarWeb extends WebPlugin {
async setData() { /* no-op on web */ }
async setTheme() { /* no-op on web */ }
async open() { /* no-op on web */ }
async close() { /* no-op on web */ }
async setEnabled() { /* no-op on web */ }
async dismiss() { /* no-op on web */ }
}

21
native-sidebar/dist/plugin.cjs.js vendored Normal file
View File

@@ -0,0 +1,21 @@
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
const core = require('@capacitor/core');
class NativeSidebarWeb extends core.WebPlugin {
async setData() {}
async setTheme() {}
async open() {}
async close() {}
async setEnabled() {}
async dismiss() {}
}
const NativeSidebar = core.registerPlugin('NativeSidebar', {
web: () => Promise.resolve(new NativeSidebarWeb()),
});
exports.NativeSidebar = NativeSidebar;
exports.NativeSidebarWeb = NativeSidebarWeb;

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSPrincipalClass</key>
<string></string>
</dict>
</plist>

View File

@@ -0,0 +1,13 @@
#import <Foundation/Foundation.h>
#import <Capacitor/Capacitor.h>
// Define the plugin using the CAP_PLUGIN Macro, and
// each method the plugin supports using the CAP_PLUGIN_METHOD macro.
CAP_PLUGIN(NativeSidebarPlugin, "NativeSidebar",
CAP_PLUGIN_METHOD(setData, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(setTheme, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(open, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(close, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(setEnabled, CAPPluginReturnPromise);
CAP_PLUGIN_METHOD(dismiss, CAPPluginReturnPromise);
)

View File

@@ -0,0 +1,88 @@
import Foundation
import Capacitor
/// Hosts a native left-edge drawer (the "sidebar") that floats above the
/// Capacitor web view. The drawer's *content* is a second, transparent
/// WKWebView that renders the existing `Sidebar.js` from data pushed in via
/// `setData`. Taps inside that content web view are forwarded back to the main
/// app as an `action` event, mirroring how the NativeInput plugin works.
@objc(NativeSidebarPlugin)
public class NativeSidebarPlugin: CAPPlugin {
private var drawer: SidebarDrawer?
// MARK: Helpers
private func hostView() -> UIView? {
// Pin the drawer as a sibling of the web view inside the same parent so
// it can float above the web content (same pattern as NativeInput).
return webView?.superview ?? webView
}
private func ensureDrawer() -> SidebarDrawer? {
if let drawer = drawer { return drawer }
guard let host = hostView() else { return nil }
let drawer = SidebarDrawer(hostView: host, mainWebView: webView, parentVC: bridge?.viewController)
drawer.onAction = { [weak self] data in
self?.notifyListeners("action", data: data)
}
drawer.onStateChange = { [weak self] open in
self?.notifyListeners("stateChange", data: ["open": open])
}
drawer.attach()
self.drawer = drawer
return drawer
}
// MARK: Plugin methods
@objc func setData(_ call: CAPPluginCall) {
// Forward the whole options object (minus Capacitor's callbackId) to the
// content web view as the sidebar payload.
var payload = (call.options as? [String: Any]) ?? [:]
payload.removeValue(forKey: "callbackId")
DispatchQueue.main.async {
guard let drawer = self.ensureDrawer() else { call.reject("No host view"); return }
drawer.setData(payload)
call.resolve()
}
}
@objc func setTheme(_ call: CAPPluginCall) {
let theme = call.getString("theme") ?? ""
DispatchQueue.main.async {
self.drawer?.setTheme(theme)
call.resolve()
}
}
@objc func open(_ call: CAPPluginCall) {
DispatchQueue.main.async {
self.ensureDrawer()?.open()
call.resolve()
}
}
@objc func close(_ call: CAPPluginCall) {
DispatchQueue.main.async {
self.drawer?.close()
call.resolve()
}
}
@objc func setEnabled(_ call: CAPPluginCall) {
let enabled = call.getBool("enabled") ?? true
DispatchQueue.main.async {
self.drawer?.setEnabled(enabled)
call.resolve()
}
}
@objc func dismiss(_ call: CAPPluginCall) {
DispatchQueue.main.async {
self.drawer?.removeFromHost()
self.drawer = nil
call.resolve()
}
}
}

View File

@@ -0,0 +1,365 @@
import UIKit
import WebKit
/// A native left-edge drawer that *pushes* the app content aside (rather than
/// overlaying it). The sidebar panel sits at a fixed position on the left,
/// behind the content; opening slides the whole content layer (the Capacitor
/// web view plus any sibling overlays such as the native chat input bar) to the
/// right to reveal it.
///
/// View layers in the host (`webView.superview`):
/// • `panelHost` — the sidebar, inserted *below* the main web view. Fixed at
/// the left, ~85% width. Hosts a transparent `contentWebView`
/// that renders the existing `Sidebar.js`.
/// • content — the main web view + other native overlays. Translated
/// right by `currentOffset` when open (visual transform only,
/// so the web layout never reflows).
/// • `self` — a transparent gesture layer on top. Catches the left-edge
/// open drag when closed, and tap / drag-to-close over the
/// shifted content when open. Everything else falls through.
class SidebarDrawer: UIView, WKScriptMessageHandler, UIGestureRecognizerDelegate {
// MARK: Callbacks to the plugin
var onAction: (([String: Any]) -> Void)?
var onStateChange: ((Bool) -> Void)?
// MARK: Tunables
private let panelRatio: CGFloat = 0.85
private let edgeStripWidth: CGFloat = 20
private let velocityToOpen: CGFloat = 500 // pt/s shortcut to decide snap
// MARK: Views
/// The sidebar, kept behind the content and revealed as content slides away.
private let panelHost = UIView()
private var contentWebView: WKWebView!
/// Owns the content web view so WebKit can find a presenting view controller
/// for native form controls (e.g. the theme `<select>`'s picker).
private let contentVC = UIViewController()
private weak var host: UIView?
private weak var mainWebView: WKWebView?
private weak var parentVC: UIViewController?
private var pan: UIPanGestureRecognizer!
// MARK: State
private var isOpen = false
private var isDragging = false
private var isAnimating = false
private var gestureEnabled = true
/// How far (pt) the content is currently pushed right. 0 = closed.
private var currentOffset: CGFloat = 0
private var dragStartOffset: CGFloat = 0
/// Origin currently loaded into the content web view, so we only load once.
private var loadedOrigin: String?
/// True once the content page has signalled it is ready to receive data.
private var contentReady = false
/// Latest payload, buffered until the content page is ready.
private var pendingData: [String: Any]?
private var panelWidth: CGFloat { (host?.bounds.width ?? bounds.width) * panelRatio }
// MARK: Lifecycle
init(hostView: UIView, mainWebView: WKWebView?, parentVC: UIViewController?) {
self.host = hostView
self.mainWebView = mainWebView
self.parentVC = parentVC
super.init(frame: .zero)
setupViews()
}
required init?(coder: NSCoder) { fatalError("init(coder:) not supported") }
func attach() {
guard let host = host else { return }
// Sidebar goes *below* the content so the content covers it when closed.
panelHost.translatesAutoresizingMaskIntoConstraints = false
if let webView = mainWebView, webView.superview === host {
host.insertSubview(panelHost, belowSubview: webView)
} else {
host.insertSubview(panelHost, at: 0)
}
// Transparent gesture layer on top of everything.
translatesAutoresizingMaskIntoConstraints = false
host.addSubview(self)
NSLayoutConstraint.activate([
panelHost.leadingAnchor.constraint(equalTo: host.leadingAnchor),
panelHost.topAnchor.constraint(equalTo: host.topAnchor),
panelHost.bottomAnchor.constraint(equalTo: host.bottomAnchor),
panelHost.widthAnchor.constraint(equalTo: host.widthAnchor, multiplier: panelRatio),
contentVC.view.leadingAnchor.constraint(equalTo: panelHost.leadingAnchor),
contentVC.view.trailingAnchor.constraint(equalTo: panelHost.trailingAnchor),
contentVC.view.topAnchor.constraint(equalTo: panelHost.topAnchor),
contentVC.view.bottomAnchor.constraint(equalTo: panelHost.bottomAnchor),
contentWebView.leadingAnchor.constraint(equalTo: contentVC.view.leadingAnchor),
contentWebView.trailingAnchor.constraint(equalTo: contentVC.view.trailingAnchor),
contentWebView.topAnchor.constraint(equalTo: contentVC.view.topAnchor),
contentWebView.bottomAnchor.constraint(equalTo: contentVC.view.bottomAnchor),
leadingAnchor.constraint(equalTo: host.leadingAnchor),
trailingAnchor.constraint(equalTo: host.trailingAnchor),
topAnchor.constraint(equalTo: host.topAnchor),
bottomAnchor.constraint(equalTo: host.bottomAnchor),
])
// Register the content view controller so WebKit can find a presenter
// for native UI (the theme <select> picker, etc.).
if let parentVC = parentVC {
parentVC.addChild(contentVC)
contentVC.didMove(toParent: parentVC)
}
}
func removeFromHost() {
contentWebView?.configuration.userContentController.removeScriptMessageHandler(forName: "sidebar")
contentVC.willMove(toParent: nil)
contentVC.removeFromParent()
applyOffset(0) // restore content to its resting position
panelHost.removeFromSuperview()
removeFromSuperview()
}
// MARK: Setup
private func setupViews() {
backgroundColor = .clear
panelHost.backgroundColor = .clear
panelHost.clipsToBounds = true
// Transparent content web view hosting Sidebar.js (it paints its own
// themed, opaque background).
let config = WKWebViewConfiguration()
config.websiteDataStore = .default()
config.userContentController.add(WeakScriptMessageProxy(self), name: "sidebar")
contentWebView = WKWebView(frame: .zero, configuration: config)
contentWebView.translatesAutoresizingMaskIntoConstraints = false
contentWebView.isOpaque = false
contentWebView.backgroundColor = .clear
contentWebView.scrollView.backgroundColor = .clear
if #available(iOS 11.0, *) { contentWebView.scrollView.contentInsetAdjustmentBehavior = .never }
// The web view lives inside the content view controller's view so it has
// an owning VC for presenting native controls.
let container = contentVC.view!
container.translatesAutoresizingMaskIntoConstraints = false
container.backgroundColor = .clear
panelHost.addSubview(container)
container.addSubview(contentWebView)
pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
pan.delegate = self
addGestureRecognizer(pan)
let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap))
tap.delegate = self
addGestureRecognizer(tap)
}
override func layoutSubviews() {
super.layoutSubviews()
// Keep the resting position correct as the host sizes (and on rotation),
// but never fight an in-flight drag/animation.
if !isDragging && !isAnimating {
applyOffset(isOpen ? panelWidth : 0)
}
}
// MARK: Content translation
/// All content layers that should slide together — everything in the host
/// except the sidebar and this gesture layer.
private func contentSiblings() -> [UIView] {
guard let host = host else { return [] }
return host.subviews.filter { $0 !== self && $0 !== panelHost }
}
private func applyOffset(_ x: CGFloat) {
currentOffset = x
let t = x == 0 ? .identity : CGAffineTransform(translationX: x, y: 0)
for v in contentSiblings() { v.transform = t }
}
// MARK: Public API
func setData(_ payload: [String: Any]) {
pendingData = payload
if let origin = payload["uiOrigin"] as? String, origin != loadedOrigin {
loadedOrigin = origin
contentReady = false
if let url = URL(string: "\(origin)/83947261/sidebar-host.html") {
contentWebView.load(URLRequest(url: url))
}
}
flushDataIfReady()
}
func setTheme(_ theme: String) {
guard contentReady, !theme.isEmpty else { return }
let safe = theme.replacingOccurrences(of: "'", with: "")
contentWebView.evaluateJavaScript("window.__sidebarSetTheme && window.__sidebarSetTheme('\(safe)')")
}
func setEnabled(_ enabled: Bool) {
gestureEnabled = enabled
pan.isEnabled = enabled
}
func open() { animate(open: true, velocity: 0) }
func close() { animate(open: false, velocity: 0) }
// MARK: Data injection
private func flushDataIfReady() {
guard contentReady, let payload = pendingData,
let data = try? JSONSerialization.data(withJSONObject: payload),
let json = String(data: data, encoding: .utf8) else { return }
contentWebView.evaluateJavaScript("window.__sidebarSetData && window.__sidebarSetData(\(json))")
}
// MARK: WKScriptMessageHandler
func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) {
guard let body = message.body as? [String: Any],
let action = body["action"] as? String else { return }
switch action {
case "ready":
contentReady = true
flushDataIfReady()
case "selectNetwork":
onAction?(["type": "selectNetwork", "abbreviation": body["abbreviation"] as? String ?? ""])
close()
case "openProfile":
onAction?(["type": "openProfile"])
close()
case "createNetwork":
onAction?(["type": "createNetwork"])
close()
case "logout":
onAction?(["type": "logout"])
close()
case "setTheme":
// The content page applies the theme to itself locally; just relay
// so the main app updates globally. Keep the drawer open.
onAction?(["type": "setTheme", "value": body["value"] as? String ?? ""])
case "close":
close()
default:
break
}
}
// MARK: Gestures
@objc private func handleTap() {
// A tap only reaches us (see hitTest) over the shifted content when open.
if isOpen { close() }
}
@objc private func handlePan(_ g: UIPanGestureRecognizer) {
guard let host = host else { return }
switch g.state {
case .began:
isDragging = true
superview?.bringSubviewToFront(self)
dragStartOffset = currentOffset
mainWebView?.allowsBackForwardNavigationGestures = false
case .changed:
let t = g.translation(in: host).x
applyOffset(min(max(dragStartOffset + t, 0), panelWidth))
case .ended, .cancelled, .failed:
isDragging = false
let v = g.velocity(in: host).x
let target: Bool
if abs(v) > velocityToOpen {
target = v > 0
} else if isOpen {
// Was open → close once dragged left past ~1/3 (stay open otherwise).
target = currentOffset > panelWidth * 2 / 3
} else {
// Was closed → open once dragged right past ~1/3.
target = currentOffset > panelWidth / 3
}
animate(open: target, velocity: v)
default:
break
}
}
private func animate(open: Bool, velocity: CGFloat) {
isAnimating = true
if open {
superview?.bringSubviewToFront(self)
mainWebView?.allowsBackForwardNavigationGestures = false
}
let target: CGFloat = open ? panelWidth : 0
let remaining = max(abs(target - currentOffset), 1)
let springVelocity = min(abs(velocity) / remaining, 4)
UIView.animate(withDuration: 0.32, delay: 0,
usingSpringWithDamping: 0.9, initialSpringVelocity: springVelocity,
options: [.allowUserInteraction, .beginFromCurrentState]) {
self.applyOffset(target)
} completion: { _ in
self.isAnimating = false
let changed = self.isOpen != open
self.isOpen = open
if !open { self.mainWebView?.allowsBackForwardNavigationGestures = true }
if changed { self.onStateChange?(open) }
}
}
// MARK: UIGestureRecognizerDelegate
override func gestureRecognizerShouldBegin(_ g: UIGestureRecognizer) -> Bool {
guard gestureEnabled else { return g is UITapGestureRecognizer ? isOpen : false }
guard let pan = g as? UIPanGestureRecognizer else { return true }
// Start position is already gated by hitTest (closed → only the edge
// strip reaches us; open → only the shifted content). Require a
// horizontal-dominant drag so vertical scrolls pass through.
let vel = pan.velocity(in: self)
return abs(vel.x) >= abs(vel.y)
}
func gestureRecognizer(_ g: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer) -> Bool {
return true
}
// MARK: hitTest — gate which touches the gesture layer claims
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if isHidden || alpha == 0 { return nil }
if isOpen || isDragging {
// The revealed sidebar (left of the content's left edge) stays
// interactive — fall through to the sidebar web view beneath.
if point.x < currentOffset { return nil }
// Over the shifted content: claim the touch (tap / drag to close).
return self
}
// Closed: only the left strip is live (to start the open drag); every
// other touch falls through to the content beneath.
if gestureEnabled && point.x <= edgeStripWidth { return self }
return nil
}
}
// MARK: - Weak message-handler proxy
/// `WKUserContentController` retains its message handlers strongly. This weak
/// proxy breaks the drawer ⇄ web view ⇄ controller retain cycle.
private final class WeakScriptMessageProxy: NSObject, WKScriptMessageHandler {
weak var target: WKScriptMessageHandler?
init(_ target: WKScriptMessageHandler) { self.target = target }
func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) {
target?.userContentController(controller, didReceive: message)
}
}

View File

@@ -0,0 +1,31 @@
{
"name": "@forum/capacitor-native-sidebar",
"version": "1.0.0",
"description": "Native iOS left-edge drawer (sidebar) for the Forum app.",
"main": "dist/plugin.cjs.js",
"module": "dist/esm/index.js",
"types": "dist/esm/index.d.ts",
"files": [
"dist/",
"ios/Plugin/",
"ForumCapacitorNativeSidebar.podspec"
],
"author": "Forum",
"license": "MIT",
"keywords": [
"capacitor",
"plugin",
"native",
"ios",
"sidebar",
"drawer"
],
"peerDependencies": {
"@capacitor/core": "^7.0.0"
},
"capacitor": {
"ios": {
"src": "ios"
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "capacitor-app",
"version": "1.0.0",
"name": "Forum",
"version": "1.0.1",
"description": "An Amazing Capacitor App",
"type": "module",
"keywords": [
@@ -15,13 +15,17 @@
"dependencies": {
"@capacitor/camera": "^7.0.2",
"@capacitor/core": "^7.4.4",
"@capacitor/filesystem": "^7.1.8",
"@capacitor/geolocation": "^7.1.5",
"@capacitor/google-maps": "^7.2.0",
"@capacitor/haptics": "^7.0.3",
"@capacitor/ios": "^7.4.4",
"@capacitor/ios": "^7.6.6",
"@capacitor/preferences": "^7.0.4",
"@capacitor/push-notifications": "^7.0.6",
"@capacitor/splash-screen": "^7.0.3"
"@capacitor/splash-screen": "^7.0.3",
"@forum/capacitor-native-input": "file:native-input",
"@forum/capacitor-native-sidebar": "file:native-sidebar",
"@notnotsamuel/capacitor-swipe-back": "^3.0.2"
},
"devDependencies": {
"@capacitor/cli": "latest",

View File

@@ -2,28 +2,15 @@
```npm run start```
### Browser: Dev Frontend and Dev Backend (localhost)
This option should be at the top level of capacitor.config.json
"server": {
"url": "http://sam.local:5173",
"cleartext": true
},
## Install Native Tools
### Browser: Prod Frontend and Prod Backend
Run:
vite build
npx serve dist
If you need to login again:
run localStorage.clear() in the browser dev tools console and then refresh the page.
## Run On Device
https://capacitorjs.com/docs/ios#adding-the-ios-platform
One-Time Install:
npm install @capacitor/ios
npx cap add ios
## Run On Device
To Open XCode:
npx cap open ios
@@ -33,24 +20,41 @@ npm run build && npx cap copy ios
If getting black screen:
npx cap sync iOS
### iOS: Dev Frontend and Dev Backend (localhost)
### Browser: Dev Front and Dev Back (localhost)
This option should be at the top level of capacitor.config.json
"server": {
"url": "http://sam.local:5173",
"cleartext": true
},
### iOS: Dev Frontend with Prod Backend (frm.so)
### Browser: Prod Front and Prod Back
Run:
vite build
npx serve dist
If you need to login again:
run localStorage.clear() in the browser dev tools console and then refresh the page.
## iOS:
### Dev Front, Dev Back (localhost)
This option should be at the top level of capacitor.config.json
"server": {
"url": "http://sam.local:5173",
"cleartext": true
},
### Dev Front, Prod Back (frm.so)
Add "https://frm.so" to VITE_API_URL in .env.development
### iOS: Prod Frontend and Prod Backend (frm.so)
Remove the "server" object from capacitor.config
### Prod Front, Prod Back (frm.so)
Remove the "server" object from capacitor.config.
### Various Commands
npx cap config - this will list the full configuration currently being used
### Architecture
In Development, API routes are routed using the vite.config.js.
### Notes

View File

@@ -1 +0,0 @@
VITE_API_URL=https://frm.so

View File

@@ -1 +0,0 @@
VITE_API_URL=https://frm.so

View File

@@ -1,106 +0,0 @@
import { Preferences } from '@capacitor/preferences';
import "./Login.js";
import "./Signup.js"
import "./EnterCode.js"
class AuthPage extends Shadow {
inputStyles(el) {
return el
.background("var(--main)")
.color("var(--text)")
.border("1px solid var(--accent)")
.fontSize(0.9, rem)
.backgroundColor("var(--searchbackground)")
.borderRadius(12, px)
.outline("none")
.onTouch((start) => {
if(start) {
this.style.backgroundColor = "var(--accent)"
} else {
this.style.backgroundColor = "var(--searchbackground)"
}
})
}
render() {
VStack(() => {
img(window.matchMedia('(prefers-color-scheme: dark)').matches ? "/_/icons/columnwhite.svg" : "/_/icons/logo.svg", window.isMobile() ? "5vmax" : "3vmax")
.marginTop(5, em)
.marginLeft(2, em)
.onClick((done) => {
window.navigateTo("/")
})
HStack(() => {
p("Login")
.state(this, "selected", function (selected) {
if(selected === "1") {
this.fontWeight("bold")
this.background("var(--loginButton)")
} else {
this.fontWeight("normal")
this.background("transparent")
}
})
.padding(0.75, em)
.borderRadius(12, px)
.onTap(() => {
this.attr("selected", "1")
})
p("Enter Code")
.state(this, "selected", function (selected) {
if(selected === "2") {
this.fontWeight("bold")
this.background("var(--loginButton)")
} else {
this.fontWeight("normal")
this.background("transparent")
}
})
.padding(0.75, em)
.borderRadius(12, px)
.onTap(() => {
this.attr("selected", "2")
})
})
.fontFamily("Arial")
.padding(0.25, em)
.borderRadius(12, px)
.background("var(--loginBackground)")
.color("var(--text)")
.horizontalAlign("center")
.margin("auto")
.marginTop(7.5, em)
.marginBottom(2, em)
.gap(0.5, em)
ZStack(() => {
Login()
.state(this, "selected", function (selected) {
if(selected === "1") {
this.display("")
} else {
this.display("none")
}
})
EnterCode()
.state(this, "selected", function (selected) {
if(selected === "2") {
this.display("flex")
} else {
this.display("none")
}
})
})
})
.attr("selected", "1")
.width(100, vw)
.height(100, vh)
.margin(0)
}
}
register(AuthPage)

View File

@@ -1,101 +0,0 @@
import util from "../../util.js"
import "./Signup.js"
class EnterCode extends Shadow {
inputStyles(el) {
return el
.background("var(--main)")
.color("var(--text)")
.border("1px solid var(--accent)")
.fontSize(0.9, rem)
.backgroundColor("var(--searchbackground)")
.borderRadius(12, px)
.outline("none")
.onTouch((start) => {
if (start) {
this.style.backgroundColor = "var(--accent)"
} else {
this.style.backgroundColor = "var(--searchbackground)"
}
})
}
render() {
VStack(() => {
VStack(() => {
p("Enter the code given to you by your organization.")
.color("#614945")
.maxWidth(70, vw)
.marginTop(3, em)
input("Code", "70vw")
.attr({ name: "firstName", type: "text" })
.margin("auto")
.marginVertical(1, em)
.padding(1, em)
.styles(this.inputStyles)
button("==>")
.padding(1, em)
.fontSize(0.9, rem)
.borderRadius(12, px)
.background("var(--searchbackground)")
.color("var(--text)")
.border("1px solid var(--accent)")
.boxSizing("border-box")
.onTouch(function (start) {
if (start) {
this.style.backgroundColor = "var(--accent)"
} else {
this.style.backgroundColor = "var(--searchbackground)"
}
})
.onClick((done) => {
if (done) this.submit()
})
})
.state(this, "codeaccepted", function (accepted) {
if(!accepted) {
this.display("flex")
} else {
this.display("none")
}
})
Signup()
.state(this, "codeaccepted", function (accepted) {
if(accepted) {
this.display("")
} else {
this.display("none")
}
})
})
.horizontalAlign("center")
.display("flex")
this.style.display = "flex"
}
async submit() {
console.log("submit")
const res = await fetch(`${util.HOST}/auth/joincode`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', "Accept": "application/json", "X-Client": "mobile" },
body: JSON.stringify({ code: this.$("input").value })
});
if (res.ok) {
console.log("got join code succ")
this.attr("codeaccepted", "true")
let { networkId } = await res.json()
$("signup-").attr("networkid", networkId)
} else {
const { error } = await res.json();
console.error(error)
}
}
}
register(EnterCode)

View File

@@ -1,147 +0,0 @@
import { Preferences } from '@capacitor/preferences';
import util from "../../util.js"
class Login extends Shadow {
inputStyles(el) {
return el
.background("var(--main)")
.color("var(--text)")
.border("1px solid var(--accent)")
.fontSize(0.9, rem)
.backgroundColor("var(--searchbackground)")
.borderRadius(12, px)
.outline("none")
.onTouch((start) => {
if (start) {
this.style.backgroundColor = "var(--accent)"
} else {
this.style.backgroundColor = "var(--searchbackground)"
}
})
}
render() {
form(() => {
VStack(() => {
input("Email", "70vw")
.attr({ name: "email", type: "email" })
.margin("auto")
.marginVertical(1, em)
.padding(1, em)
.styles(this.inputStyles)
input("Password", "70vw")
.attr({ name: "password", type: "password" })
.margin("auto")
.marginVertical(1, em)
.padding(1, em)
.styles(this.inputStyles)
HStack(() => {
button("==>")
.padding(1, em)
.fontSize(0.9, rem)
.borderRadius(12, px)
.background("var(--searchbackground)")
.color("var(--text)")
.border("1px solid var(--accent)")
.boxSizing("border-box")
.onTouch(function (start) {
if (start) {
this.style.backgroundColor = "var(--accent)"
} else {
this.style.backgroundColor = "var(--searchbackground)"
}
})
})
.width(70, vw)
.margin("auto")
.fontSize(0.9, rem)
.paddingLeft(0, em)
.paddingRight(2, em)
.marginVertical(1, em)
.border("1px solid transparent")
p("")
.state("errortype", function (type) {
const messages = {
email: "Please enter a valid email.",
password: "Please enter a valid password.",
emailwrong: "Could not find an account with this email.",
passwordwrong: "Incorrect password.",
};
if(messages[type]) {
this.display("")
this.innerText = messages[type]
} else {
this.display("none")
this.innerText = ""
}
})
.margin("auto")
.marginTop(1, em)
.color("var(--text)")
.fontFamily("Arial")
.opacity(.7)
.padding(0.5, em)
.backgroundColor("var(--darkred)")
.display("none")
})
})
.height(100, pct)
.onSubmit(async (e) => {
e.preventDefault();
const data = {
email: e.target.$('[name="email"]').value,
password: e.target.$('[name="password"]').value,
};
await this.requestLogin(data);
})
}
isValidEmail = (email) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
isValidPassword = (password) => password.length >= 7;
async requestLogin(data) {
const emailValid = this.isValidEmail(data.email.trim() || "");
const passValid = this.isValidPassword(data.password.trim() || "");
if (!emailValid || !passValid) {
console.log("invalid", emailValid)
const errorType = !emailValid ? 'email' : 'password';
this.$("p").attr({ errorType });
return;
} else {
this.$("p").attr({ errorType: "" });
}
const res = await fetch(`${util.HOST}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json", "X-Client": "mobile" },
body: JSON.stringify({
email: data["email"],
password: data["password"]
})
});
if (res.ok) {
const { token } = await res.json();
await Preferences.set({ key: 'auth_token', value: token });
global.renderHome();
} else {
const { error } = await res.json();
this.errorMessage = error;
console.error(error)
if(error.includes("email")) {
this.$("p").attr({ errorType: "emailwrong" });
} else {
this.$("p").attr({ errorType: "passwordwrong" });
}
}
}
}
register(Login)

View File

@@ -1,210 +0,0 @@
import { Preferences } from '@capacitor/preferences';
import util from "../../util.js"
class Signup extends Shadow {
inputStyles(el) {
return el
.background("var(--main)")
.color("var(--text)")
.border("1px solid var(--accent)")
.fontSize(0.9, rem)
.backgroundColor("var(--searchbackground)")
.borderRadius(12, px)
.outline("none")
.onTouch((start) => {
if (start) {
this.style.backgroundColor = "var(--accent)"
} else {
this.style.backgroundColor = "var(--searchbackground)"
}
})
}
render() {
form(() => {
VStack(() => {
input("First Name", "70vw")
.attr({ name: "firstName", type: "text" })
.margin("auto")
.marginVertical(1, em)
.padding(1, em)
.styles(this.inputStyles)
input("Last Name", "70vw")
.attr({ name: "lastName", type: "text" })
.margin("auto")
.marginVertical(1, em)
.padding(1, em)
.styles(this.inputStyles)
input("Email", "70vw")
.attr({ name: "email", type: "email" })
.margin("auto")
.marginVertical(1, em)
.padding(1, em)
.styles(this.inputStyles)
input("Password", "70vw")
.attr({ name: "password", type: "password" })
.margin("auto")
.marginVertical(1, em)
.padding(1, em)
.styles(this.inputStyles)
input("Confirm Password", "70vw")
.attr({ name: "confirmPassword", type: "password" })
.margin("auto")
.marginVertical(1, em)
.padding(1, em)
.styles(this.inputStyles)
p("")
.state("errormessage", function (msg) {
if(msg) {
this.display("")
this.innerText = msg
} else {
this.display("none")
this.innerText = ""
}
})
.margin("auto")
.marginTop(1, em)
.color("var(--text)")
.fontFamily("Arial")
.opacity(.7)
.padding(0.5, em)
.backgroundColor("var(--darkred)")
.display("none")
HStack(() => {
button("==>")
.padding(1, em)
.fontSize(0.9, rem)
.borderRadius(12, px)
.background("var(--searchbackground)")
.color("var(--text)")
.border("1px solid var(--accent)")
.boxSizing("border-box")
.onTouch(function (start) {
if (start) {
this.style.backgroundColor = "var(--accent)"
} else {
this.style.backgroundColor = "var(--searchbackground)"
}
})
})
.width(70, vw)
.margin("auto")
.fontSize(0.9, rem)
.paddingLeft(0, em)
.paddingRight(2, em)
.marginVertical(1, em)
.marginBottom(10, em)
.border("1px solid transparent")
})
})
.height(100, pct)
.onSubmit(async (e) => {
e.preventDefault();
const data = new FormData(e.target);
if (this.verifyInput(data)) {
this.errorMessage = "";
await this.requestSignup(data);
} else {
console.log(this.errorMessage)
}
})
}
async requestSignup(data) {
const networkId = this.attr("networkid");
const res = await fetch(`${util.HOST}/auth/signup`, {
method: "POST",
headers: { "Content-Type": "application/json", "X-Client": "mobile" },
body: JSON.stringify({
networkId: networkId,
firstName: data.get("firstName"),
lastName: data.get("lastName"),
email: data.get("email"),
password: data.get("password")
})
});
if (res.ok) {
const { token } = await res.json();
await Preferences.set({ key: 'auth_token', value: token });
global.renderHome();
} else {
const { error } = await res.json();
console.error(error)
this.$("p").attr("errormessage", error)
}
}
verifyInput(data) {
const firstName = data.get("firstName");
const lastName = data.get("lastName");
const email = data.get("email");
const password = data.get("password");
const confirmPassword = data.get("confirmPassword");
if (!firstName || firstName.trim() === "") {
this.$("p").attr("errormessage", "First name is required.")
return false
} else if (firstName.trim().length < 2) {
this.$("p").attr("errormessage", "First name must be at least 2 characters.")
return false
} else if (!/^[a-zA-Z\s'-]+$/.test(firstName.trim())) {
this.$("p").attr("errormessage", "First name contains invalid characters.")
return false
}
if (!lastName || lastName.trim() === "") {
this.$("p").attr("errormessage", "Last name is required.")
return false
} else if (lastName.trim().length < 2) {
this.$("p").attr("errormessage", "Last name must be at least 2 characters.")
return false
} else if (!/^[a-zA-Z\s'-]+$/.test(lastName.trim())) {
this.$("p").attr("errormessage", "Last name contains invalid characters.")
return false
}
if (!email || email.trim() === "") {
this.$("p").attr("errormessage", "Email is required.")
return false
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) {
this.$("p").attr("errormessage", "Please enter a valid email address.")
return false
}
let passwordError = `Password must be at least 7 characters and include an uppercase letter, a lowercase letter, a number, and a special character (!@#$%^&*(),.?":{}|<>)`
if (!password) {
this.$("p").attr("errormessage", "Password is required.")
return false
} else if (password.length < 7) {
this.$("p").attr("errormessage", passwordError)
return false
} else if (!/[A-Z]/.test(password)) {
this.$("p").attr("errormessage", passwordError)
return false
} else if (!/[a-z]/.test(password)) {
this.$("p").attr("errormessage", passwordError)
return false
} else if (!/[0-9]/.test(password)) {
this.$("p").attr("errormessage", passwordError)
return false
} else if (!/[!@#$%^&*(),.?":{}|<>]/.test(password)) {
this.$("p").attr("errormessage", passwordError)
return false
}
if (!confirmPassword) {
this.$("p").attr("errormessage", "Please confirm your password.")
return false
} else if (confirmPassword !== password) {
this.$("p").attr("errormessage", "Passwords do not match.")
return false
}
return true;
}
}
register(Signup)

View File

@@ -1,23 +0,0 @@
class ConnectionError extends Shadow {
render() {
VStack(() => {
img("/_/icons/column.svg", window.isMobile() ? "5vmax" : "3vmax")
.position("absolute")
.top(2, em)
.left(2, em)
.onClick((done) => {
window.navigateTo("/")
})
p("Error connecting to server. Please try again later!")
})
.verticalAlign("center")
.paddingHorizontal(20, vw)
.backgroundColor("var(--main)")
.overflowX("hidden")
.height(100, vh)
}
}
register(ConnectionError)

View File

@@ -1,153 +0,0 @@
import "../components/Sidebar.js"
import "../components/AppMenu.js"
import "../components/AppWindowContainer.js"
css(`
#homeContainer {
-webkit-user-select: none;
}
`)
/*
Sidebar Functionality Checklist:
- Open on Top left network logo touch (WITH transition)
- Follow finger on swipe from left side of the screen
- Open if finger swipw travels far enough to the right (WITH velocity-based transition)
- Re-close if not opened enough of the way (WITH transition)
- Close on touch of home contents (WITH transition)
- Follow finger on swipe beginning near or anywhere on right of divider between sidebar and home contents
- Close if finger swipe travels far enough to the left (WITH velocity-based transition)
- Re-open if not closed enough of the way (WITH transition)
*/
class Home extends Shadow {
dragStartX = null
sidebarOpen = false
SIDEBAR_FULL_OPEN = (window.outerWidth * 5) / 6
SIDEBAR_START_THRESHOLD = window.outerWidth / 10
SIDEBAR_CLOSE_DECISION = (window.outerWidth * 2) / 3
SIDEBAR_OPEN_DECISION = (window.outerWidth / 3)
constructor() {
super()
}
render() {
ZStack(() => {
Sidebar(this.SIDEBAR_FULL_OPEN)
ZStack(() => {
VStack(() => {
AppWindowContainer()
AppMenu()
})
.height(100, pct)
.minHeight(0)
})
.left(0, px)
.backgroundColor("var(--main)")
.overflowX("hidden")
.height(window.visualViewport.height, px)
.position("fixed")
.borderLeft("1px solid var(--accent)")
.onTouch((start, e) => {
if(this.sidebarOpen) {
this.sidebarOpenTouch(start, e)
} else {
this.sidebarClosedTouch(start, e)
}
})
.attr({ id: "homeContainer" })
})
.userSelect("none")
}
openSidebar(duration = 200) {
const home = this.$("#homeContainer");
home.style.transition = `left ${duration}ms`;
home.style.left = `${this.SIDEBAR_FULL_OPEN}px`;
this.sidebarOpen = true;
this.dragStartX = null
setTimeout(() => home.style.transition = "", duration);
}
closeSidebar(duration = 200) {
const home = this.$("#homeContainer");
home.style.transition = `left ${duration}ms`;
home.style.left = "0px";
this.sidebarOpen = false;
this.dragStartX = null
setTimeout(() => home.style.transition = "", duration);
}
sidebarOpenTouch(start, e) {
if(start) {
let amount = e.targetTouches[0].clientX
if(amount > (this.SIDEBAR_FULL_OPEN - this.SIDEBAR_START_THRESHOLD)) {
this.dragStartX = e.touches[0].clientX
this.dragStartTime = Date.now(); // ⬅ track start time
document.addEventListener("touchmove", this.moveSidebar)
}
} else {
if(!this.dragStartX) return;
let endX = e.changedTouches[0].clientX
let duration = this.getDuration(this.dragStartX, endX);
if(Math.abs(this.dragStartX - endX) < 5) { // 2 conditions are separated so this one doesn't close instantly
this.closeSidebar()
} else if(endX < this.SIDEBAR_CLOSE_DECISION) {
this.closeSidebar(duration)
} else {
this.openSidebar(duration)
}
document.removeEventListener("touchmove", this.moveSidebar)
}
}
sidebarClosedTouch(start, e) {
if(start) {
let amount = e.targetTouches[0].clientX
if(amount < this.SIDEBAR_START_THRESHOLD) {
this.dragStartX = e.touches[0].clientX
this.dragStartTime = Date.now();
document.addEventListener("touchmove", this.moveSidebar)
}
} else {
if(!this.dragStartX) return;
let endX = e.changedTouches[0].clientX
let duration = this.getDuration(this.dragStartX, endX);
if(endX > this.SIDEBAR_OPEN_DECISION) {
this.openSidebar(duration)
} else {
this.closeSidebar(duration)
}
document.removeEventListener("touchmove", this.moveSidebar)
}
}
moveSidebar = (e) => {
let amount = e.targetTouches[0].clientX
if(e.targetTouches[0] && amount < this.SIDEBAR_FULL_OPEN) {
this.$("#homeContainer").style.left = `${amount}px`
}
}
getDuration(startX, endX) {
const distance = Math.abs(endX - startX);
const elapsed = Date.now() - this.dragStartTime;
const velocity = distance / elapsed; // px per ms
const duration = Math.round(distance / velocity); // time to cover remaining distance
return Math.min(Math.max(duration, 50), 200); // clamp between 50ms and 200ms
}
}
register(Home)

View File

@@ -1,222 +0,0 @@
import util from "../util";
css(`
profile- textarea::-webkit-scrollbar {
display: none;
width: 0px;
height: 0px;
}
profile- textarea::-webkit-scrollbar-thumb {
background: transparent;
}
profile- textarea::-webkit-scrollbar-track {
background: transparent;
}
`)
class Profile extends Shadow {
constructor() {
super()
this.profile = global.profile
this.bioText = global.profile.bio ?? ""
}
render() {
ZStack(() => {
div("➩")
.width(3, rem)
.height(3, rem)
.borderRadius(50, pct)
.border("1.5px solid var(--divider)")
.position("absolute")
.fontSize(2, em)
.transform("rotate(180deg)")
.top(3, rem)
.left(2, rem)
.zIndex(1001)
.display("flex")
.alignItems("center")
.justifyContent("center")
.transition("scale .2s")
.state("touched", function (touched) {
if(touched) {
this.scale("1.5")
this.color("var(--darkaccent)")
this.backgroundColor("var(--divider)")
} else {
this.scale("")
this.color("var(--divider)")
this.backgroundColor("var(--darkaccent)")
}
})
.onTouch(function (start) {
if(start) {
this.attr({touched: "true"})
} else {
this.attr({touched: ""})
}
})
.onClick((done) => {
if(done)
$("appwindowcontainer-").closeProfile()
})
form(() => {
input("Image Upload", "0px", "0px")
.attr({ name: "image-upload", type: "file" })
.display("none")
.visibility("hidden")
.onChange((e) => {
this.handleUpload(e.target.files[0]);
})
VStack(() => {
HStack(() => {
if (global.profile.image_path) {
img(`${util.HOST}${global.profile.image_path}`, "10em", "10em")
.borderRadius(100, pct)
}
})
.boxSizing("border-box")
.height(10, em)
.width(10, em)
.border("1px solid var(--accent)")
.borderRadius(100, pct)
.background("var(--darkaccent)")
.onTap(() => {
const inputSelector = this.$('[name="image-upload"]');
inputSelector.click()
})
p("Tap to edit")
.color("var(--headertext)")
.opacity(0.5)
.marginTop(0.5, em)
h1(this.profile.first_name + " " + this.profile.last_name)
.color("var(--headertext")
.width(70, pct)
.marginVertical(0.25, em)
.textAlign("center")
p("Joined " + this.convertDate(this.profile.created))
.color("var(--headertext)")
.marginBottom(0.5, em)
h2("Bio")
.color("var(--headertext")
.margin(0)
.paddingVertical(0.9, em)
.borderTop("2px solid var(--divider)")
.width(70, pct)
.textAlign("center")
textarea(this.bioText ? this.bioText : "Tap to start typing...")
.attr({ name: "bioinput" })
.padding(1, em)
.width(90, pct)
.height(15, em)
.boxSizing("border-box")
.background("var(--searchbackground)")
.color("var(--darktext)")
.border("1px solid color-mix(in srgb, var(--accent) 60%, transparent)")
.borderRadius(12, px)
.fontFamily("Arial")
.fontSize(1.1, em)
.outline("none")
.onAppear((e) => {
if (this.bioText) {
$("profile- textarea").innerText = this.bioText
}
})
.lineHeight(1.2, em)
button("Save Bio")
.padding(1, em)
.fontSize(1.1, em)
.borderRadius(12, px)
.background("var(--searchbackground)")
.color("var(--text)")
.border("1px solid var(--accent)")
.boxSizing("border-box")
.marginVertical(0.75, em)
})
.horizontalAlign("center")
.marginTop(5, em)
})
.onSubmit(async (e) => {
e.preventDefault();
const newBio = new FormData(e.target).get("bioinput");
console.log(this.profile)
if (newBio.trim() !== this.profile.bio?.trim()) {
const result = await server.editBio(newBio, this.profile.id)
const { bio, updated_at } = result.data
global.profile.bio = bio
global.profile.updated_at = updated_at
this.profile = global.profile
}
})
})
.backgroundColor("var(--main)")
.overflowX("hidden")
.height(window.visualViewport.height - 20, px)
.boxSizing("border-box")
.width(100, pct)
.position("fixed")
.top(100, vh)
.zIndex(5)
.transition("top .4s ")
.pointerEvents("none")
}
async handleUpload(file) {
try {
const body = new FormData();
body.append('image', file);
const res = await util.authFetch(`${util.HOST}/profile/upload-image`, {
method: "POST",
credentials: "include",
headers: {
"Accept": "application/json"
},
body: body
});
if(res.status === 401) {
return res.status
}
if (!res.ok) return res.status;
const data = await res.json()
global.profile = data.member
console.log(global.profile)
} catch (err) { // Network error / Error reaching server
console.error(err);
}
}
convertDate(rawDate) {
const parsed = new Date(rawDate);
if (isNaN(parsed.getTime())) return rawDate;
const month = parsed.toLocaleString("en-US", { month: "long", timeZone: "UTC" });
const day = parsed.getUTCDate();
const year = parsed.getUTCFullYear();
const ordinal = (n) => {
const mod100 = n % 100;
if (mod100 >= 11 && mod100 <= 13) return `${n}th`;
switch (n % 10) {
case 1: return `${n}st`;
case 2: return `${n}nd`;
case 3: return `${n}rd`;
default: return `${n}th`;
}
};
return `${month} ${ordinal(day)}, ${year}`;
}
}
register(Profile)

View File

@@ -1,110 +0,0 @@
:root {
--main: #FFE9C8;
--accent: #570b0b;
--darkaccent: #dfc9ac;
--text: #340000;
--yellow: #f1f3c3;
--bone: #fff2e7;
--gold: #FEBA7D;
--divider: #bb7c36;
--green: #0857265c;
--red: #ff0000;
--quillred: #DE3F3F;
--darkred: #6b2c1d;
--brown: #812A18;
--sidebar: #698b6f;
--divider: #523636;
--darkbrown: #3f0808;
--darkgrey: #5c4646;
--headertext: #433c36e2;
--searchbackground: #ffeed8;
--loginButton: var(--main);
--loginBackground: #d96b6b;
--home-src: /_/icons/home.svg;
--home-selected-src: /_/icons/homelightselected.svg;
--people-src: /_/icons/people.svg;
--people-selected-src: /_/icons/peoplelightselected.svg;
--settings-src: /_/icons/settings.svg;
--settings-selected-src: /_/icons/settingslightselected.svg;
--events-src: /_/icons/events.svg;
--events-selected-src: /_/icons/eventslightselected.svg;
--jobs-src: /_/icons/jobs.svg;
--jobs-selected-src: /_/icons/jobslightselected.svg;
--column-src: /_/icons/column2.svg;
--nodes-src: /_/icons/nodes.svg;
--forum-src: /_/icons/forum.svg;
--trash-src: /_/icons/trash.svg;
--pin-src: /_/icons/pin.svg;
--time-src: /_/icons/time.svg;
--top-inset: env(safe-area-inset-top, 0px);
--bottom-inset: env(safe-area-inset-bottom, 0px);
}
@media (prefers-color-scheme: dark) {
:root {
--main: #2A150E;
--accent: #3D2622;
--darkaccent: #240609;
--text: #FADFB6;
--sidebar: #240609;
--divider: #523636;
--darktext: #62473E;
--headertext: #ffd8bb;
--darkred: #6b2c1d;
--searchbackground: #260F0C;
--loginButton: var(--darkaccent);
--loginBackground: var(--accent);
--home-src: /_/icons/homelight.svg;
--home-selected-src: /_/icons/homelightselected.svg;
--people-src: /_/icons/peoplelight.svg;
--people-selected-src: /_/icons/peoplelightselected.svg;
--settings-src: /_/icons/settingslight.svg;
--settings-selected-src: /_/icons/settingslightselected.svg;
--events-src: /_/icons/eventslight.svg;
--events-selected-src: /_/icons/eventslightselected.svg;
--jobs-src: /_/icons/jobslight.svg;
--jobs-selected-src: /_/icons/jobslightselected.svg;
--column-src: /_/icons/column2.svg;
--nodes-src: /_/icons/nodes.svg;
--forum-src: /_/icons/forum.svg;
--trash-src: /_/icons/trash.svg;
--pin-src: /_/icons/pinlight.svg;
--time-src: /_/icons/timelight.svg;
}
}
input {
outline: none;
caret-color: var(--text); /* hide real caret */
}
input::placeholder {
font-family: Arial;
color: #5C504D;
}
html,
body {
padding: 0;
margin: 0;
font-family: Arial;
}
body {
background-color: var(--main);
padding-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom);
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}

View File

@@ -1,68 +0,0 @@
import { Preferences } from '@capacitor/preferences';
import util from "../../../util.js"
class Connection {
connectionTries = 0;
ws;
receiveCB;
constructor(receiveCB) {
this.receiveCB = receiveCB;
}
init = async () => {
const { value: token } = await Preferences.get({ key: 'auth_token' });
return new Promise((resolve, reject) => {
let url = ""
if(util.HOST) {
url = "wss://" + util.HOST.replace(/^https?:\/\//, '') + "/ws" + `?token=${token}`;
} else {
url = "ws://" + window.location.host + "/ws"
}
this.ws = new WebSocket(url);
this.ws.addEventListener('open', () => {
this.connectionTries = 0;
console.log("WebSocket connection established.");
this.ws.addEventListener('message', this.receiveCB);
resolve(this.ws); // resolve when open
});
this.ws.addEventListener('close', () => {
console.log('WebSocket closed');
this.checkOpen(); // attempt reconnection
});
this.ws.addEventListener('error', (err) => {
console.error('WebSocket error', err);
reject(err); // reject if error occurs
});
});
}
checkOpen = async () => {
if (this.ws.readyState === WebSocket.OPEN) {
return true;
} else {
await this.sleep(this.connectionTries < 20 ? 5000 : 60000);
this.connectionTries++;
console.log('Reestablishing connection');
await this.init();
}
}
sleep = (time) => new Promise(resolve => setTimeout(resolve, time));
send = (msg) => {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send(msg);
} else if (this.connectionTries === 0) {
setTimeout(() => this.send(msg), 100);
} else {
console.error('No WebSocket connection: Cannot send message');
}
}
}
export default Connection;

View File

@@ -1,49 +0,0 @@
import Connection from "./Connection.js";
export default class Socket {
connection;
disabled = true;
requestID = 1;
pending = new Map();
constructor() {
this.connection = new Connection(this.receive);
}
async init() {
await this.connection.init()
}
isOpen() {
if(this.connection.checkOpen()) {
return true;
} else {
return false;
}
}
send(msg) {
return new Promise(resolve => {
const id = (++this.requestID).toString();
this.pending.set(id, resolve);
this.connection.send(JSON.stringify({ id, ...msg }));
});
}
receive = (event) => {
const msg = JSON.parse(event.data);
if (msg.id && this.pending.has(msg.id)) {
this.pending.get(msg.id)(msg);
this.pending.delete(msg.id);
return;
} else {
this.onBroadcast(msg)
}
}
onBroadcast(msg) {
window.dispatchEvent(new CustomEvent(msg.event, {
detail: msg.msg
}));
}
}

View File

@@ -1 +0,0 @@
export default {}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -1,83 +0,0 @@
// import './ForumPanel.js'
// css(`
// forum- {
// font-family: 'Bona';
// }
// forum- input::placeholder {
// font-family: 'Bona Nova';
// font-size: 0.9em;
// color: var(--accent);
// }
// input::placeholder {
// font-family: Arial;
// }
// input[type="checkbox"] {
// appearance: none; /* remove default style */
// -webkit-appearance: none;
// width: 1em;
// height: 1em;
// border: 1px solid var(--accent);
// }
// input[type="checkbox"]:checked {
// background-color: var(--red);
// }
// `)
// class Forum extends Shadow {
// render() {
// ZStack(() => {
// VStack(() => {
// ForumPanel()
// input("Message", "70%")
// .paddingVertical(0.75, em)
// .boxSizing("border-box")
// .paddingHorizontal(2, em)
// .color("var(--accent)")
// .background("black")
// .marginBottom(1, em)
// .border("0.5px solid #6f5e4e")
// .borderRadius(100, px)
// .fontFamily("Arial")
// .fontSize(1, em)
// .onKeyDown(async function(e) {
// if (e.key === "Enter") {
// let msg = {
// forum: global.currentNetwork.abbreviation,
// text: this.value
// }
// await global.Socket.send({
// app: "FORUM",
// operation: "SEND",
// msg: msg
// })
// this.value = ""
// }
// })
// })
// .gap(0.5, em)
// .boxSizing("border-box")
// .width(100, pct)
// .height(100, pct)
// .horizontalAlign("center")
// .verticalAlign("end")
// .minHeight(0)
// })
// .backgroundColor("var(--main)")
// .boxSizing("border-box")
// .paddingVertical(1, em)
// .width(100, pct)
// .minHeight(0)
// .flex("1 1 auto")
// }
// }
// register(Forum)

View File

@@ -1,193 +0,0 @@
// import "../../components/LoadingCircle.js"
// css(`
// forumpanel- {
// scrollbar-width: none;
// -ms-overflow-style: none;
// }
// forumpanel-::-webkit-scrollbar {
// display: none;
// width: 0px;
// height: 0px;
// }
// forumpanel-::-webkit-scrollbar-thumb {
// background: transparent;
// }
// forumpanel-::-webkit-scrollbar-track {
// background: transparent;
// }
// `)
// class ForumPanel extends Shadow {
// messages = []
// isSending = false
// render() {
// VStack(() => {
// if(this.messages.length > 0) {
// let previousDate = null
// for(let i=0; i<this.messages.length; i++) {
// let message = this.messages[i]
// const isMe = message.authorId === global.profile.id
// const dateParts = this.parseDate(message.time);
// const { date, time } = dateParts;
// if (previousDate !== date) {
// previousDate = date;
// p(date)
// .textAlign("center")
// .opacity(0.6)
// .fontWeight("bold")
// .paddingTop(1, em)
// .paddingBottom(0.5, em)
// .color("var(--quillred)")
// .borderTop(`1px solid var(--${i == 0 ? "transparent" : "divider"})`)
// }
// VStack(() => {
// HStack(() => {
// h3(isMe ? "Me" : message.sentBy)
// .color(isMe ? "var(--quillred)" : "var(--brown")
// .margin(0)
// h3(`${date} ${time}`)
// .opacity(0.5)
// .color("var(--brown)")
// .margin(0)
// .marginLeft(0.5, em)
// .fontSize(1, em)
// if (message.edited) {
// p("(edited)")
// .color("var(--brown)")
// .letterSpacing(0.8, "px")
// .opacity(0.8)
// .fontWeight("bold")
// .paddingLeft(0.25, em)
// .fontSize(0.9, em)
// }
// })
// .verticalAlign("center")
// .marginBottom(0.1, em)
// p(message.text)
// .color("var(--accent)")
// .borderLeft("1.5px solid var(--divider)")
// .borderBottomLeftRadius("7.5px")
// .paddingLeft(0.5, em)
// .marginHorizontal(0.2, em)
// .paddingVertical(0.2, em)
// .boxSizing("border-box")
// })
// .marginBottom(0.05, em)
// .onClick(async (finished, e) => {
// if (finished) {
// console.log(message.id)
// let msg = {
// forum: global.currentNetwork.abbreviation,
// id: message.id,
// text: "EDITED TEXT TEST!"
// }
// await global.Socket.send({
// app: "FORUM",
// operation: "PUT",
// msg: msg
// })
// }
// })
// }
// } else {
// LoadingCircle()
// }
// })
// .gap(1, em)
// .fontSize(1.1, em)
// .boxSizing("border-box")
// .flex("1 1 auto")
// .minHeight(0)
// .overflowY("auto")
// .width(100, pct)
// .paddingBottom(2, em)
// .paddingHorizontal(4, pct)
// .backgroundColor("var(--main)")
// .onAppear(async () => {
// requestAnimationFrame(() => {
// this.scrollTo({ top: 0, behavior: "smooth" });
// });
// if (!this.isSending) {
// this.isSending = true
// let res = await global.Socket.send({
// app: "FORUM",
// operation: "GET",
// msg: {
// forum: global.currentNetwork.abbreviation,
// by: "network",
// authorId: -999 // default
// }
// })
// if(!res) console.error("failed to get messages")
// if(res.msg.length > 0 && this.messages.length === 0) {
// this.messages = res.msg.reverse()
// this.rerender()
// }
// this.isSending = false
// }
// })
// .onEvent("new-post", this.onNewPost)
// .onEvent("deleted-post", this.onDeletedPost)
// .onEvent("edited-post", this.onEditedPost)
// }
// onNewPost = (e) => {
// let newPost = e.detail
// if (this.messages && !this.messages.some(post => post.id === newPost.id)) {
// this.messages.unshift(newPost)
// this.rerender()
// }
// }
// onDeletedPost = (e) => {
// let deletedId = e.detail
// const i = this.messages.findIndex(post => post.id === deletedId)
// if (i !== -1) this.messages.splice(i, 1);
// this.rerender()
// }
// onEditedPost = (e) => {
// let editedPost = e.detail
// const i = this.messages.findIndex(post => post.id === editedPost.id)
// if (i !== -1) {
// this.messages.splice(i, 1)
// this.messages.unshift(editedPost)
// }
// this.rerender()
// }
// parseDate(str) {
// // Format: MM.DD.YYYY-HH:MM:SSxxxxxx(am|pm)
// const match = str.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})-(\d{1,2}):(\d{2}).*(am|pm)$/i);
// if (!match) return null;
// const [, mm, dd, yyyy, hh, min, ampm] = match;
// const date = `${mm}/${dd}/${yyyy}`;
// const time = `${hh}:${min}${ampm.toLowerCase()}`;
// return { date, time };
// }
// formatTime(str) {
// const match = str.match(/-(\d+:\d+):\d+.*(am|pm)/i);
// if (!match) return null;
// const [_, hourMin, ampm] = match;
// return hourMin + ampm.toLowerCase();
// }
// }
// register(ForumPanel)

View File

@@ -1,105 +0,0 @@
import "./MarketSidebar.js"
import "./MarketGrid.js"
css(`
market- {
font-family: 'Bona';
}
market- input::placeholder {
font-family: 'Bona Nova';
font-size: 0.9em;
color: var(--accent);
}
input[type="checkbox"] {
appearance: none; /* remove default style */
-webkit-appearance: none;
width: 1em;
height: 1em;
border: 1px solid var(--accent);
}
input[type="checkbox"]:checked {
background-color: var(--red);
}
`)
class Market extends Shadow {
listings = [
{
title: "Shield Lapel Pin",
stars: "5",
reviews: 1,
price: "$12",
company: "Hyperia",
type: "new",
image: "/db/images/1",
madeIn: "America"
}
]
render() {
ZStack(() => {
HStack(() => {
MarketSidebar()
MarketGrid(this.listings)
})
.width(100, "%")
.x(0).y(13, vh)
HStack(() => {
input("Search for products... (Coming Soon!)", "45vw")
.attr({
"type": "text",
"disabled": "true"
})
.fontSize(1.1, em)
.paddingLeft(1.3, em)
.background("transparent")
.border("0.5px solid var(--divider)")
.outline("none")
.color("var(--accent)")
.opacity(0.5)
.borderRadius(10, px)
.background("grey")
.cursor("not-allowed")
button("+ Add Item")
.width(7, em)
.marginLeft(1, em)
.borderRadius(10, px)
.background("transparent")
.border("0.5px solid var(--accent2)")
.color("var(--accent)")
.fontFamily("Bona Nova")
.onHover(function (hovering) {
if(hovering) {
this.style.background = "var(--green)"
} else {
this.style.background = "transparent"
}
})
.onClick((clicking) => {
console.log(this, "clicked")
})
})
.x(55, vw).y(4, vh)
.position("absolute")
.transform("translateX(-50%)")
})
.width(100, "%")
.height(100, "%")
}
connectedCallback() {
// Optional additional logic
}
}
register(Market)

View File

@@ -1,140 +0,0 @@
class MarketGrid extends Shadow {
listings;
constructor(listings) {
super()
this.listings = listings
}
boldUntilFirstSpace(text) {
if(!text) return
const index = text.indexOf(' ');
if (index === -1) {
// No spaces — bold the whole thing
return `<b>${text}</b>`;
}
return `<b>${text.slice(0, index)}</b>${text.slice(index)}`;
}
render() {
VStack(() => {
h3("Results")
.marginTop(0.1, em)
.marginBottom(1, em)
.marginLeft(0.4, em)
.color("var(--accent)")
.opacity(0.7)
if (this.listings.length > 0) {
ZStack(() => {
// BuyModal()
let params = new URLSearchParams(window.location.search);
const hyperiaMade = params.get("hyperia-made") === "true";
const americaMade = params.get("america-made") === "true";
const newItem = params.get("new") === "true";
const usedItem = params.get("used") === "true";
let filtered = this.listings;
if (hyperiaMade) {
filtered = filtered.filter(item => item.madeIn === "Hyperia");
}
if (americaMade) {
filtered = filtered.filter(item => item.madeIn === "America");
}
if (newItem) {
filtered = filtered.filter(item => item.type === "new");
}
if (usedItem) {
filtered = filtered.filter(item => item.type === "used");
}
for (let i = 0; i < filtered.length; i++) {
const rating = filtered[i].stars
const percent = (rating / 5)
VStack(() => {
img(filtered[i].image)
.marginBottom(0.5, em)
p(filtered[i].company)
.marginBottom(0.5, em)
p(filtered[i].title)
.fontSize(1.2, em)
.fontWeight("bold")
.marginBottom(0.5, em)
HStack(() => {
p(filtered[i].stars)
.marginRight(0.2, em)
ZStack(() => {
div("★★★★★") // Empty stars (background)
.color("#ccc")
div("★★★★★") // Filled stars (foreground, clipped by width)
.color("#ffa500")
.position("absolute")
.top(0)
.left(0)
.whiteSpace("nowrap")
.overflow("hidden")
.width(percent * 5, em)
})
.display("inline-block")
.position("relative")
.fontSize(1.2, em)
.lineHeight(1)
p(filtered[i].reviews)
.marginLeft(0.2, em)
})
.marginBottom(0.5, em)
p(filtered[i].price)
.fontSize(1.75, em)
.marginBottom(0.5, em)
button("Coming Soon!")
.onClick((finished) => {
if(finished) {
}
})
.onHover(function (hovering) {
if(hovering) {
this.style.backgroundColor = "var(--green)"
} else {
this.style.backgroundColor = ""
}
})
})
.padding(1, em)
.border("1px solid var(--accent2)")
.borderRadius(5, "px")
}
})
.display("grid")
.gridTemplateColumns("repeat(auto-fill, minmax(250px, 1fr))")
.gap(1, em)
} else {
p("No Listings!")
}
})
.onQueryChanged(() => {
console.log("query did change yup")
this.rerender()
})
.height(100, vh)
.paddingLeft(2, em)
.paddingRight(2, em)
.gap(0, em)
.width(100, "%")
}
}
register(MarketGrid)

View File

@@ -1,85 +0,0 @@
class MarketSidebar extends Shadow {
handleChecked(e) {
let checked = e.target.checked
let label = $(`label[for="${e.target.id}"]`).innerText
if(checked) {
window.setQuery(label.toLowerCase(), true)
} else {
window.setQuery(label.toLowerCase(), null)
}
}
render() {
VStack(() => {
p("Make")
HStack(() => {
input()
.attr({
"type": "checkbox",
"id": "hyperia-check"
})
.onChange(this.handleChecked)
label("Hyperia-Made")
.attr({
"for": "hyperia-check"
})
.marginLeft(0.5, em)
})
HStack(() => {
input()
.attr({
"type": "checkbox",
"id": "america-check"
})
.onChange(this.handleChecked)
label("America-Made")
.attr({
"for": "america-check"
})
.marginLeft(0.5, em)
})
p("Condition")
HStack(() => {
input()
.attr({
"type": "checkbox",
"id": "new-check"
})
.onChange(this.handleChecked)
label("New")
.attr({
"for": "new-check"
})
.marginLeft(0.5, em)
})
HStack(() => {
input()
.attr({
"type": "checkbox",
"id": "used-check"
})
.onChange(this.handleChecked)
label("Used")
.attr({
"for": "used-check"
})
.marginLeft(0.5, em)
})
})
.paddingTop(12, vh)
.paddingLeft(3, em)
.paddingRight(3, em)
.gap(1, em)
.minWidth(10, vw)
.userSelect('none')
}
}
register(MarketSidebar)

View File

@@ -1,189 +0,0 @@
import "./MessagesSidebar.js"
import "./MessagesPanel.js"
css(`
messages- {
font-family: 'Bona';
}
messages- input::placeholder {
font-family: 'Bona Nova';
font-size: 0.9em;
color: var(--accent);
}
input[type="checkbox"] {
appearance: none; /* remove default style */
-webkit-appearance: none;
width: 1em;
height: 1em;
border: 1px solid var(--accent);
}
input[type="checkbox"]:checked {
background-color: var(--red);
}
`)
class Messages extends Shadow {
conversations = []
selectedConvoID = null
onConversationSelect(i) {
console.log("convo selected: ", i)
this.selectedConvoID = i
this.$("messagessidebar-").rerender()
this.$("messagespanel-").rerender()
}
getConvoFromID(id) {
for(let i=0; i<this.conversations.length; i++) {
if(this.conversations[i].id === id) {
return this.conversations[i]
}
}
}
render() {
ZStack(() => {
HStack(() => {
MessagesSidebar(this.conversations, this.selectedConvoID, this.onConversationSelect)
VStack(() => {
if(this.getConvoFromID(this.selectedConvoID)) {
MessagesPanel(this.getConvoFromID(this.selectedConvoID).messages)
} else {
MessagesPanel()
}
input("Send Message", "93%")
.paddingVertical(1, em)
.paddingHorizontal(2, em)
.color("var(--accent)")
.background("var(--darkbrown)")
.marginBottom(6, em)
.border("none")
.fontSize(1, em)
.onKeyDown((e) => {
if (e.key === "Enter") {
window.Socket.send({app: "MESSAGES", operation: "SEND", msg: { conversation: `CONVERSATION-${this.selectedConvoID}`, text: e.target.value }})
e.target.value = ""
}
})
})
.gap(1, em)
.width(100, pct)
.horizontalAlign("center")
.verticalAlign("end")
})
.onAppear(async () => {
let res = await global.Socket.send({app: "MESSAGES", operation: "GET"})
if(!res) console.error("failed to get messages")
if(res.msg.length > 0 && this.conversations.length === 0) {
this.conversations = res.msg
this.selectedConvoID = this.conversations[0].id
this.rerender()
}
window.addEventListener("new-message", (e) => {
let convoID = e.detail.conversationID
let messages = e.detail.messages
let convo = this.getConvoFromID(convoID)
convo.messages = messages
this.rerender()
})
})
.width(100, pct)
.height(87, pct)
.x(0).y(13, pct)
VStack(() => {
p("Add Message")
input("enter email...")
.color("var(--accent)")
.onKeyDown(function (e) {
if (e.key === "Enter") {
window.Socket.send({app: "MESSAGES", operation: "ADDCONVERSATION", msg: {email: this.value }})
this.value = ""
}
})
p("x")
.onClick(function (done) {
if(done) {
this.parentElement.style.display = "none"
}
})
.xRight(2, em).y(2, em)
.fontSize(1.4, em)
.cursor("pointer")
})
.gap(1, em)
.verticalAlign("center")
.horizontalAlign("center")
.backgroundColor("black")
.border("1px solid var(--accent)")
.position("fixed")
.x(50, vw).y(50, pct)
.center()
.width(60, vw)
.height(60, pct)
.display("none")
.attr({id: "addPanel"})
HStack(() => {
input("Search messages... (Coming Soon!)", "45vw")
.attr({
"type": "text",
"disabled": "true"
})
.fontSize(1.1, em)
.paddingLeft(1.3, em)
.background("transparent")
.border("0.5px solid var(--divider)")
.outline("none")
.color("var(--accent)")
.opacity(0.5)
.borderRadius(10, px)
.background("grey")
.cursor("not-allowed")
button("+ New Message")
.width(13, em)
.marginLeft(1, em)
.borderRadius(10, px)
.background("transparent")
.border("0.5px solid var(--divider)")
.color("var(--accent)")
.fontFamily("Bona Nova")
.onHover(function (hovering) {
if(hovering) {
this.style.background = "var(--green)"
} else {
this.style.background = "transparent"
}
})
.onClick((done) => {
console.log("click")
if(done) {
this.$("#addPanel").style.display = "flex"
}
console.log(this, "clicked")
})
})
.x(55, vw).y(4, pct)
.position("absolute")
.transform("translateX(-50%)")
})
.boxSizing("border-box")
.height(100, pct)
.width(100, pct)
}
}
register(Messages)

View File

@@ -1,56 +0,0 @@
import "../../components/LoadingCircle.js"
class MessagesPanel extends Shadow {
messages
constructor(messages) {
super()
this.messages = messages
}
render() {
VStack(() => {
if(this.messages) {
for(let i=0; i<this.messages.length; i++) {
let message = this.messages[i]
let fromMe = window.profile.email === message.from.email
VStack(() => {
HStack(() => {
p(message.from.firstName + " " + message.from.lastName)
.fontWeight("bold")
.marginBottom(0.3, em)
p(util.formatTime(message.time))
.opacity(0.2)
.marginLeft(1, em)
})
p(message.text)
})
.paddingVertical(0.5, em)
.marginLeft(fromMe ? 70 : 0, pct)
.paddingRight(fromMe ? 10 : 0, pct)
.marginRight(fromMe ? 0 : 70, pct)
.paddingLeft(fromMe ? 5 : 10, pct)
.background(fromMe ? "var(--brown)" : "var(--green)")
}
} else {
LoadingCircle()
}
})
.onAppear(async () => {
requestAnimationFrame(() => {
this.scrollTop = this.scrollHeight
});
})
.gap(1, em)
.position("relative")
.overflow("scroll")
.height(95, pct)
.width(100, pct)
.paddingTop(2, em)
.paddingBottom(2, em)
.backgroundColor("var(--darkbrown)")
}
}
register(MessagesPanel)

View File

@@ -1,73 +0,0 @@
class MessagesSidebar extends Shadow {
conversations = []
selectedConvoID
onSelect
constructor(conversations, selectedConvoID, onSelect) {
super()
this.conversations = conversations
this.selectedConvoID = selectedConvoID
this.onSelect = onSelect
}
render() {
VStack(() => {
this.conversations.forEach((convo, i) => {
VStack(() => {
HStack(() => {
p(this.makeConvoTitle(convo.between))
.textAlign("left")
.marginLeft(0.5, inches)
.paddingTop(0.2, inches)
.width(100, pct)
.marginTop(0)
.fontSize(1, em)
.fontWeight("bold")
p(util.formatTime(convo.messages.last.time))
.paddingTop(0.2, inches)
.fontSize(0.8, em)
.marginRight(0.1, inches)
.color("var(--divider")
})
.justifyContent("space-between")
.marginBottom(0)
p(convo.messages.last.text)
.fontSize(0.8, em)
.textAlign("left")
.marginLeft(0.5, inches)
.marginBottom(2, em)
.color("var(--divider)")
})
.background(convo.id === this.selectedConvoID ? "var(--darkbrown)" : "")
.onClick(() => {
this.onSelect(i)
})
})
})
.minWidth(15, vw)
.height(100, vh)
.gap(0, em)
}
makeConvoTitle(members) {
let membersString = ""
for(let i=0; i<members.length; i++) {
let member = members[i]
if(member.email === window.profile.email) {
continue;
}
if(members.length > 2) {
membersString += member.firstName
} else {
membersString += member.firstName + " " + member.lastName
}
}
return membersString
}
}
register(MessagesSidebar)

View File

@@ -1,153 +0,0 @@
css(`
tasks- {
font-family: 'Bona';
}
tasks- input::placeholder {
font-family: 'Bona Nova';
font-size: 0.9em;
color: var(--accent);
}
input[type="checkbox"] {
appearance: none; /* remove default style */
-webkit-appearance: none;
width: 1em;
height: 1em;
border: 1px solid var(--accent);
}
input[type="checkbox"]:checked {
background-color: var(--red);
}
`)
class Tasks extends Shadow {
projects = [
{
"title": "Blockcatcher",
"tasks": {}
}
]
columns = [
{
"title": "backlog",
"tasks": {}
}
]
render() {
ZStack(() => {
HStack(() => {
VStack(() => {
h3("Projects")
.marginTop(0)
.marginBottom(1, em)
.marginLeft(0.4, em)
if (this.projects.length >= 1) {
for(let i = 0; i < this.projects.length; i++) {
p(this.projects[i].title)
}
} else {
p("No Projects!")
}
})
.height(100, vh)
.paddingLeft(2, em)
.paddingRight(2, em)
.paddingTop(2, em)
.gap(0, em)
.borderRight("0.5px solid var(--accent2)")
HStack(() => {
if (this.columns.length >= 1) {
for(let i = 0; i < this.columns.length; i++) {
p(this.columns[i].name)
}
} else {
p("No Conversations!")
}
})
.height(100, vh)
.paddingLeft(2, em)
.paddingRight(2, em)
.paddingTop(2, em)
.gap(0, em)
.borderRight("0.5px solid var(--accent2)")
})
.width(100, "%")
.x(0).y(13, vh)
.borderTop("0.5px solid var(--accent2)")
p("0 Items")
.position("absolute")
.x(50, vw).y(50, vh)
.transform("translate(-50%, -50%)")
HStack(() => {
input("Search tasks...", "45vw")
.attr({
"type": "text"
})
.fontSize(1.1, em)
.paddingLeft(1.3, em)
.background("transparent")
.border("0.5px solid var(--accent2)")
.outline("none")
.color("var(--accent)")
.borderRadius(10, px)
button("Search")
.marginLeft(2, em)
.borderRadius(10, px)
.background("transparent")
.border("0.5px solid var(--accent2)")
.color("var(--accent)")
.fontFamily("Bona Nova")
.onHover(function (hovering) {
if(hovering) {
this.style.background = "var(--green)"
} else {
this.style.background = "transparent"
}
})
button("+ New Task")
.width(9, em)
.marginLeft(1, em)
.borderRadius(10, px)
.background("transparent")
.border("0.5px solid var(--accent2)")
.color("var(--accent)")
.fontFamily("Bona Nova")
.onHover(function (hovering) {
if(hovering) {
this.style.background = "var(--green)"
} else {
this.style.background = "transparent"
}
})
.onClick((clicking) => {
console.log(this, "clicked")
})
})
.x(55, vw).y(4, vh)
.position("absolute")
.transform("translateX(-50%)")
})
.width(100, "%")
.height(100, "%")
}
connectedCallback() {
// Optional additional logic
}
}
register(Tasks)

View File

@@ -1,72 +0,0 @@
import { Haptics, ImpactStyle } from '@capacitor/haptics';
import util from "../util.js"
class AppMenu extends Shadow {
selected = ""
apps = global.currentNetwork.apps.filter(app => (app !== "Settings" && app !== "Website"))
darkMode = window.matchMedia('(prefers-color-scheme: dark)').matches
getImageURL(appName) {
let imgUrl = `${util.HOST}/db/apps/${appName}/icons/${appName}`
if(this.darkMode) {
imgUrl += "light"
}
imgUrl += ".svg"
imgUrl = imgUrl.toLowerCase()
return imgUrl
}
onNewSelection() {
this.$$("img").forEach((image) => {
image.style.borderBottom = "1px solid transparent"
const appName = image.attributes.app.value
if (appName === global.currentApp()) {
image.style.borderBottom = "1px solid var(--text)"
}
image.src = this.getImageURL(appName)
})
}
render() {
let apps = this.apps
let appCount = apps.length
let horizontalMargin = {
1: 50,
2: 10,
3: 2,
4: 1.5,
5: 0.5
}[appCount] ?? 4
HStack(() => {
for(let i = 0; i < apps.length; i++) {
let app = apps[i]
img(this.getImageURL(app), "1.3em")
.attr({app: app})
.padding(0.5, em)
.borderBottom(global.currentApp() === app ? "1px solid var(--text)" : "1px solid transparent")
.onTouch(async (done, e) => {
if(done) {
global.openApp(app)
this.onNewSelection()
await Haptics.impact({ style: ImpactStyle.Light });
}
})
}
})
.display("grid")
.gridTemplateColumns(`repeat(${apps.filter(app => app !== "Settings").length}, 1fr)`)
.placeItems("center")
.borderTop("0.5px solid var(--divider)")
.height("auto")
.zIndex(1)
.paddingTop(0.5, em)
.paddingBottom(2, em)
.width(100, vw)
.boxSizing("border-box")
}
}
register(AppMenu)

View File

@@ -1,30 +0,0 @@
import util from "../util.js"
class AppWindow extends Shadow {
render() {
ZStack(() => {
let app = global.currentApp()
if(window[app]) {
window[app]()
} else {
this.getCustomApp(app)
}
})
.height(100, pct)
.overflowY("scroll")
.onNavigate(() => {
this.rerender()
})
}
async getCustomApp(app) {
await import(`${util.HOST}/apps/${app.toLowerCase()}/${app.toLowerCase()}.js`);
if(window[app]) {
this.rerender()
} else {
console.error("Could not get app: ", app)
}
}
}
register(AppWindow)

View File

@@ -1,37 +0,0 @@
import "./AppWindow.js"
import "../Profile/Profile.js"
import "./TopBar.js"
class AppWindowContainer extends Shadow {
render() {
ZStack(() => {
VStack(() => {
TopBar()
AppWindow()
})
.width(100, pct)
.gap(0)
Profile()
.zIndex(3)
})
.height(100, pct)
.overflowY("hidden")
.display("flex")
.position("relative")
}
openProfile() {
this.$("profile-").top(20, px)
this.$("profile-").pointerEvents("auto")
}
closeProfile() {
this.$("profile-").top(100, vh)
this.$("profile-").pointerEvents("none")
}
}
register(AppWindowContainer)

View File

@@ -1,127 +0,0 @@
import util from "../util"
import "./Toggle.js"
class Sidebar extends Shadow {
SIDEBAR_WIDTH
constructor(width) {
super()
this.SIDEBAR_WIDTH = width
}
SidebarItem(text) {
return p(text)
.fontSize(1.2, em)
.fontWeight("bold")
.color("var(--headertext)")
.fontFamily("Arial")
.marginLeft(3, em)
.marginTop(2, em)
.onTap(function (done) {
if (done) {
if (this.innerText === "Logout") {
global.onLogout()
$("home-").closeSidebar();
return
}
}
})
}
render() {
VStack(() => {
HStack(() => {
if (global.profile.image_path) {
img(`${util.HOST}${global.profile.image_path}`, "10em", "10em")
.borderRadius(100, pct)
}
})
.boxSizing("border-box")
.height(10, em)
.width(10, em)
.border("1px solid var(--accent)")
.borderRadius(100, pct)
.background("var(--darkaccent)")
.alignSelf("center")
.onClick((done) => {
if(done)
this.openProfile()
})
h2(global.profile.first_name + " " + global.profile.last_name)
.color("var(--headertext")
.textAlign("center")
.marginVertical(0.25, em)
.paddingBottom(0.5, em)
.textAlign("center")
.alignSelf("center")
.overflowWrap("break-word")
.wordBreak("break-word")
.width(100, pct)
.borderBottom("2px solid var(--divider)")
.onClick((done) => {
if(done)
this.openProfile()
})
.paddingBottom(1, em)
this.SidebarItem("Logout")
VStack(() => {
Toggle("Enable Push Notifications")
.marginLeft(1, em)
button("Delete Account")
.fontSize(0.9, em)
.marginBottom(2, em)
.background("var(--darkred)")
.paddingVertical(1, em)
.border("none")
.outline("1px solid var(--divider)")
.color("var(--text)")
.onTap((done) => this.deleteAccount())
})
.marginTop("auto")
.gap(2, em)
})
.gap(1, em)
.paddingTop(15, vh)
.paddingHorizontal(1, em)
.height(105, vh)
.top(-5, vh)
.minWidth(0)
.boxSizing("border-box")
.width(this.SIDEBAR_WIDTH, px)
.borderLeft("1px solid var(--divider)")
.color("var(--text)")
.position("fixed")
.background("var(--sidebar)")
}
openProfile() {
$("appwindowcontainer-").openProfile()
$("home-").closeSidebar();
}
async deleteAccount() {
try {
const res = await util.authFetch(`${util.HOST}/auth/delete`, {
method: "DELETE",
credentials: "include",
headers: {
"X-Client": "mobile",
"Content-Type": "application/json",
"Accept": "application/json"
},
body: JSON.stringify({ memberId: global.profile.id })
});
if (!res.ok) return;
global.onLogout()
} catch (err) {
console.error(err)
}
}
}
register(Sidebar)

View File

@@ -1,50 +0,0 @@
css(`
.toggle-input {
appearance: none;
width: 44px;
height: 24px;
background: #ccc;
border-radius: 12px;
position: relative;
cursor: pointer;
transition: background 0.2s;
}
.toggle-input::after {
content: '';
position: absolute;
width: 20px;
height: 20px;
background: white;
border-radius: 50%;
top: 2px;
left: 2px;
transition: left 0.2s;
}
.toggle-input:checked {
background: var(--darkred);
}
.toggle-input:checked::after {
left: 22px;
}
`)
class Toggle extends Shadow {
render() {
HStack(() => {
input("", "44px", "24px")
.attr({ type: "checkbox", checked: global.profile.preferences.notifications ? "" : null, class: "toggle-input"})
.onChange(async (e) => {
await server.updateNotificationPreferences(global.profile.id, e.target.checked)
})
p("Enable Push Notifications")
.color("var(--text)")
})
.verticalAlign("center")
.gap(10, px)
}
}
register(Toggle)

View File

@@ -1,53 +0,0 @@
import util from "../util.js"
class TopBar extends Shadow {
render() {
HStack(() => {
if (global.currentNetwork.logo) {
img(`${util.HOST}/db/images/${global.currentNetwork.logo}`, "2.5em", "2.5em")
.borderRadius("50", pct)
.objectFit("cover")
.padding(0.3, em)
.background("var(--accent)")
.onTouch(function (start) {
if(start) {
this.style.scale = "0.8"
} else if(start === false) {
this.style.scale = ""
if (!$("home-").sidebarOpen) {
$("home-").openSidebar()
}
}
})
} else {
HStack(() => { })
.height(2.5, em)
.width(2.5, em)
.padding(0.3, em)
.background("var(--accent)")
.borderRadius("50", pct)
}
p()
.state("app", function () {
this.innerText = global.currentApp()
})
.color("var(--headertext)")
.textAlign("center")
.fontFamily("Arial")
.fontSize("clamp(0.8rem, 40cqw, 7cqw)")
.fontWeight("bold")
})
.containerType("inline-size")
.paddingLeft(1, em)
.paddingBottom(1.5, em)
.verticalAlign("center")
.gap(0.5, em)
.marginTop(4, em)
.onNavigate(() => {
this.$("p").attr({app: global.currentApp()})
})
}
}
register(TopBar)

View File

@@ -9,21 +9,124 @@
/>
<meta name="format-detection" content="telephone=no" />
<meta name="msapplication-tap-highlight" content="no" />
<script
type="module"
src="https://unpkg.com/@ionic/pwa-elements@latest/dist/ionicpwaelements/ionicpwaelements.esm.js"
></script>
<script
nomodule
src="https://unpkg.com/@ionic/pwa-elements@latest/dist/ionicpwaelements/ionicpwaelements.js"
></script>
<link rel="icon" type="image/x-icon" href="./_/icons/columnwhite.svg" />
<link rel="manifest" href="./manifest.json" />
<link rel="stylesheet" href="./_/code/shared.css" />
<script src="/_/code/quill.js"></script>
<script type="module" src="./index.js"></script>
<link rel="stylesheet" href="" />
<script>window.config = { UI: 'https://frm.so', SERVER: 'https://frm.so' }</script>
<script type="module">
await import('./mobileutil.js')
function appendScript(src, isModule = false) {
return new Promise((resolve, reject) => {
const s = document.createElement('script')
s.src = src
s.crossOrigin = "anonymous"
if (isModule) s.type = 'module'
s.onload = resolve
s.onerror = reject
document.head.appendChild(s)
})
}
function appendStylesheet(href, { replaceExisting = false } = {}) {
return new Promise((resolve, reject) => {
if (replaceExisting) {
document.querySelector('link[rel="stylesheet"]')?.remove();
}
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = href;
link.onload = resolve;
link.onerror = reject;
document.head.appendChild(link);
});
}
try {
await appendStylesheet(`${window.config.UI}/_/code/shared.css`, { replaceExisting: true });
await appendScript(window.config.UI + '/_/code/quill.js')
await appendScript(window.config.UI + '/_/lib/hls.min.js')
await appendScript(window.config.UI + '/83947261/index.js', true)
} catch (e) {
document.body.innerHTML = `
<style>
#ptr-screen {
position: fixed; inset: 0;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
font-family: sans-serif; text-align: center;
padding: 2rem; touch-action: none;
transition: transform 0.2s ease;
color: var(--text);
}
#ptr-icon {
font-size: 2rem; margin-bottom: 1rem;
transition: transform 0.2s ease, opacity 0.2s ease;
opacity: 0.4;
}
#ptr-label {
font-size: 0.9rem; opacity: 0.5;
margin-top: 0.5rem;
}
#ptr-indicator {
color: var(--text);
position: fixed; top: 5vh; left: 0; right: 0;
display: flex; align-items: center; justify-content: center;
padding-top: env(safe-area-inset-top);
height: 0; overflow: hidden;
transition: height 0.1s ease;
font-size: 0.8rem; opacity: 0.6; gap: 0.4rem;
}
@keyframes spin { to { transform: rotate(360deg) } }
.spinning { animation: spin 0.6s linear infinite }
</style>
<div id="ptr-indicator">
<span id="ptr-arrow">↓</span>
<span id="ptr-hint">Pull to retry</span>
</div>
<div id="ptr-screen">
<div id="ptr-icon">⚠️</div>
<p style="margin:0;font-size:1.1rem;font-weight:600">No connection</p>
<p id="ptr-label">Could not reach the server.<br>Pull down to try again.</p>
</div>
`
const THRESHOLD = 90
let startY = 0, dragging = false
document.addEventListener('touchstart', e => {
startY = e.touches[0].clientY
dragging = true
})
document.addEventListener('touchmove', e => {
if (!dragging) return
const dy = Math.max(0, e.touches[0].clientY - startY)
const pull = Math.min(dy, THRESHOLD * 1.5)
const progress = Math.min(pull / THRESHOLD, 1)
document.getElementById('ptr-screen').style.transform = `translateY(${pull * 0.4}px)`
document.getElementById('ptr-indicator').style.height = (pull * 0.6) + 'px'
document.getElementById('ptr-arrow').style.transform = `rotate(${progress * 180}deg)`
document.getElementById('ptr-hint').textContent = progress >= 1 ? 'Release to retry' : 'Pull to retry'
document.getElementById('ptr-icon').style.opacity = 0.4 + progress * 0.6
})
document.addEventListener('touchend', e => {
if (!dragging) return
dragging = false
const dy = e.changedTouches[0].clientY - startY
if (dy >= THRESHOLD) {
document.getElementById('ptr-arrow').textContent = '↻'
document.getElementById('ptr-arrow').classList.add('spinning')
document.getElementById('ptr-hint').textContent = 'Retrying…'
setTimeout(() => location.reload(), 400)
} else {
document.getElementById('ptr-screen').style.transform = ''
document.getElementById('ptr-indicator').style.height = '0'
}
})
}
</script>
<meta name="theme-color" content="#31d53d" />
</head>
<body>

View File

@@ -1,223 +0,0 @@
import { PushNotifications } from '@capacitor/push-notifications';
import Socket from "/_/code/ws/Socket.js"
import "./Home/Home.js"
import "./Home/AuthPage/AuthPage.js"
import "./Home/ConnectionError.js"
import util from "./util.js"
const env = import.meta.env
let Global = class {
Socket = new Socket()
profile = null
currentNetwork = ""
lastApp = ""
util = util
currentApp() {
const pathname = window.location.pathname;
const segments = pathname.split('/').filter(Boolean)
const secondSegment = segments[1] || ""
const capitalized = secondSegment.charAt(0).toUpperCase() + secondSegment.slice(1);
return capitalized
}
openApp = function(appName) {
const appUrl = appName.charAt(0).toLowerCase() + appName.slice(1);
let parts = window.location.pathname.split('/').filter(Boolean);
let newPath = "/" + parts[0] + "/" + appUrl
window.navigateTo(newPath)
const event = new CustomEvent('appchange', {
detail: { name: appName }
});
window.dispatchEvent(event)
}
async fetchAppData() {
let personalSpace = this.currentNetwork === this.profile
if (personalSpace) { return {} }
let appData = await fetch(`${util.HOST}/api/${personalSpace ? "my" : "org"}data/` + this.currentNetwork.id, {method: "GET"})
let json = await appData.json()
return json
}
onNavigate = async () => {
if(!global.profile) return
let selectedNetwork = this.networkFromPath()
if(!selectedNetwork) {
if (!this.currentNetwork || this.currentNetwork === this.profile) {
let path = `/${this.getDefaultNetworkName()}/${this.getDefaultAppName()}`
history.replaceState({}, '', path)
}
} else if(!this.currentApp()) {
if(this.currentNetwork === window.profile) {
history.replaceState({}, '', `${window.location.pathname}/${window.profile.apps[0]}`)
} else {
history.replaceState({}, '', `${window.location.pathname}/${this.getDefaultAppName()}`)
}
}
selectedNetwork = this.networkFromPath()
let networkChanged = this.currentNetwork !== selectedNetwork
let appChanged = this.currentApp() !== this.lastApp
if(appChanged) {
this.lastApp = this.currentApp()
}
if(networkChanged) {
this.currentNetwork = selectedNetwork
const event = new CustomEvent('networkchange', {
detail: { name: this.currentNetwork }
});
window.dispatchEvent(event)
}
if(!this.currentNetwork?.data) {
this.currentNetwork.data = await this.fetchAppData()
}
if(appChanged && !networkChanged) {
const event = new CustomEvent('appchange', {
detail: { name: this.currentApp() }
});
window.dispatchEvent(event)
}
document.title = (this.currentNetwork === this.profile) ? "Forum" : `${this.currentNetwork.abbreviation} | Forum`
}
setCurrentNetworkAndApp() {
this.currentNetwork = this.networkFromPath()
}
getDefaultNetworkName() {
let defaultNetwork = this.profile.networks[0]
if (!defaultNetwork) { return "my"; }
return defaultNetwork.abbreviation
}
getDefaultAppName() {
let defaultNetwork = this.profile.networks[0]
if (!defaultNetwork) { return this.profile.apps[0].toLowerCase(); }
return defaultNetwork.apps[0].toLowerCase()
}
networkFromPath = function () {
const pathname = window.location.pathname;
const firstSegment = pathname.split('/').filter(Boolean)[0] || '';
if(firstSegment === "my") {
return this.profile
} else {
let networks = this.profile.networks
for(let i = 0; i < networks.length; i++) {
let network = networks[i]
if(network.abbreviation === firstSegment) {
return network
}
}
}
}
async getProfile() {
try {
const res = await util.authFetch(`${util.HOST}/auth/profile`, {
method: "GET",
credentials: "include",
headers: {
"Accept": "application/json"
}
});
if(res.status === 401) {
return res.status
}
if (!res.ok) return res.status;
const profile = await res.json();
console.log("getProfile: ", profile);
this.profile = profile
return 200;
} catch (err) { // Network error / Error reaching server
console.error(err);
return 500;
}
}
async renderHome() {
location.reload()
}
async onLogout() {
await util.removeAuthToken()
await fetch(`${util.HOST}/auth/signout`, {
method: "GET",
credentials: "include"
});
this.profile = null
location.reload()
}
async setupPushNotifications() {
if (!Capacitor.isNativePlatform()) return;
PushNotifications.addListener('registration', async (token) => {
console.log('Device token:', token.value)
const stored = localStorage.getItem('deviceToken')
if (stored === token.value) return;
console.log("new push token")
await server.updatePushToken(token.value, global.profile.id, env.MODE)
localStorage.setItem('deviceToken', token.value)
});
PushNotifications.addListener('registrationError', (error) => {
console.error('Registration error:', JSON.stringify(error))
});
PushNotifications.addListener('pushNotificationReceived', (notification) => {
console.log('Notification received:', notification)
});
PushNotifications.addListener('pushNotificationActionPerformed', (action) => {
console.log('Notification tapped:', action.notification)
// navigate somewhere based on action.notification.data
});
const permission = await PushNotifications.requestPermissions();
if (permission.receive === 'granted') {
await PushNotifications.register();
console.log('after register:')
}
}
async init() {
try {
const module = await import(`${util.HOST}/@server/server.js`);
window.server = module.default;
} catch(E) {
console.error(E)
}
window.addEventListener("navigate", this.onNavigate)
this.getProfile().then(async (status) => {
if (status === 401) {
navigateTo("/")
AuthPage()
} else if(status === 500) {
ConnectionError()
} else {
await this.Socket.init()
await this.setupPushNotifications()
await this.onNavigate()
Home()
}
})
}
constructor() {
this.init()
}
}
window.global = new Global()

47
src/mobileutil.js Normal file
View File

@@ -0,0 +1,47 @@
import { PushNotifications } from '@capacitor/push-notifications';
import { Preferences } from '@capacitor/preferences';
import { Filesystem, Directory } from '@capacitor/filesystem';
import { Haptics, ImpactStyle } from '@capacitor/haptics';
import { Camera, CameraResultType, CameraSource } from '@capacitor/camera';
import { Geolocation } from '@capacitor/geolocation';
import { SplashScreen } from '@capacitor/splash-screen';
import { CapacitorSwipeBackPlugin } from '@notnotsamuel/capacitor-swipe-back';
import { NativeInput } from '@forum/capacitor-native-input';
import { NativeSidebar } from '@forum/capacitor-native-sidebar';
window.capacitor = {
Preferences,
PushNotifications,
Filesystem,
Directory,
Haptics,
ImpactStyle,
Camera,
CameraResultType,
CameraSource,
Geolocation,
SplashScreen,
CapacitorSwipeBackPlugin,
NativeInput,
NativeSidebar
}
window.mobileUtil = class mobileUtil {
static async authFetch(url, options = {}) {
const { value: token } = await Preferences.get({ key: 'auth_token' });
return fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${token}`,
'X-Client': 'mobile'
}
});
}
static async removeAuthToken() {
await Preferences.remove({ key: 'auth_token'})
}
}

File diff suppressed because one or more lines are too long

View File

@@ -1,4 +0,0 @@
<svg width="28" height="32" viewBox="0 0 28 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.52366 0.0493342C4.88415 0.328928 1.12711 1.09781 0.253382 1.93659L0 2.18124V3.45688V4.73253L0.244645 4.9597C0.541713 5.23929 0.917417 5.43152 1.69504 5.69363C2.42023 5.94702 2.60372 5.96449 2.44645 5.77227C1.99211 5.22182 3.27649 4.584 5.7142 4.16461C8.0558 3.75395 9.35765 3.67532 13.5428 3.67532C17.728 3.67532 19.0299 3.75395 21.3715 4.16461C23.8354 4.584 25.0935 5.22182 24.6305 5.78974C24.5169 5.9208 24.5344 5.92954 24.7877 5.87712C25.3382 5.77227 26.4915 5.26551 26.7886 5.01212L27.0856 4.75001V3.45688V2.16376L26.7886 1.90164C25.9498 1.16771 22.8743 0.4862 18.7852 0.136707C17.3523 0.00564766 11.1401 -0.0467762 9.52366 0.0493342Z" fill="black"/>
<path d="M10.6246 5.30045C8.06453 5.44899 5.65304 5.82469 4.49971 6.26156C3.80073 6.52367 3.49492 6.83822 3.49492 7.27508V7.62458L4.0978 7.61584C4.63077 7.6071 4.73562 7.63331 4.93658 7.82553C5.06764 7.94786 5.20743 8.11386 5.25986 8.20997C5.31228 8.31482 5.33849 11.3292 5.32976 16.79L5.32102 25.2128H5.76662H6.20349V16.423C6.20349 6.60231 6.16854 7.15276 6.79762 6.89064C7.18207 6.73337 7.75873 6.80327 8.06453 7.03918C8.58877 7.45857 8.56256 6.82948 8.56256 18.1268V28.4456H9.17417H9.78578V17.8734C9.78578 11.4428 9.81199 7.24013 9.86442 7.14402C10.0741 6.75958 10.3974 6.56736 10.9216 6.53241C11.5158 6.48873 11.9526 6.68968 12.1361 7.0916C12.2148 7.26635 12.241 10.1671 12.2322 19.4549V31.591H13.5865H14.9408V19.4636C14.9408 7.59836 14.9408 7.33624 15.1155 7.06539C15.6136 6.24408 16.9853 6.34893 17.3436 7.24013C17.4571 7.52846 17.4746 8.89148 17.4746 18.0132V28.4543L18.0687 28.4281L18.6541 28.4019L18.6279 18.2229C18.6017 11.2069 18.6279 7.94786 18.6891 7.7469C18.9774 6.82948 20.2443 6.48873 20.7861 7.18771C20.9695 7.41488 20.9695 7.4673 20.9695 16.3095V25.2128H21.4064H21.8433V16.8424C21.8433 8.708 21.852 8.47209 22.018 8.20124C22.2714 7.77311 22.5597 7.63331 23.1189 7.64205H23.6169L23.5645 7.2314C23.5296 6.94307 23.4597 6.76832 23.2937 6.63726C22.1403 5.63247 16.0155 4.99465 10.6246 5.30045Z" fill="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

View File

@@ -1,9 +0,0 @@
<svg width="306" height="315" viewBox="0 0 306 315" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M257.568 6.10352e-05H47.9351V28.2306H257.568V6.10352e-05Z" fill="#FFD5A4"/>
<path d="M207.362 174.274C205.545 172.318 203.134 171.234 200.549 171.234C195.203 171.234 190.836 176.021 190.836 181.891V314.449H210.262V181.891C210.262 178.991 209.249 176.301 207.362 174.274Z" fill="#FFD5A4"/>
<path d="M233.844 314.448V121.795C229.581 123.472 224.97 124.416 220.148 124.416H85.3542C80.5326 124.416 75.9207 123.472 71.6582 121.795V314.448H90.0011V181.89C90.0011 173.12 96.7093 165.993 104.955 165.993C109.008 165.993 112.781 167.67 115.611 170.71C118.371 173.679 119.909 177.662 119.909 181.89V314.448H137.797V156.524C137.797 147.755 144.506 140.627 152.751 140.627C156.769 140.627 160.542 142.304 163.407 145.309C166.203 148.314 167.705 152.297 167.705 156.524V314.448H185.594V181.89C185.594 173.12 192.302 165.993 200.547 165.993C204.6 165.993 208.374 167.67 211.204 170.71C213.964 173.679 215.501 177.662 215.501 181.89V314.448H233.844Z" fill="#FFD5A4"/>
<path d="M159.562 148.909C157.71 146.953 155.3 145.87 152.749 145.87C147.403 145.87 143.036 150.656 143.036 156.526V314.449H162.462V156.526C162.462 153.626 161.449 150.936 159.562 148.909Z" fill="#FFD5A4"/>
<path d="M111.769 174.274C109.952 172.318 107.542 171.234 104.956 171.234C99.6105 171.234 95.2432 176.021 95.2432 181.891V314.449H114.669V181.891C114.669 178.991 113.656 176.301 111.769 174.274Z" fill="#FFD5A4"/>
<path d="M245.831 106.423C233.916 98.9114 225.95 85.6696 225.95 70.611C225.95 70.4363 225.95 70.2965 225.95 70.1218H79.5569C79.5569 70.1218 79.5569 70.4363 79.5569 70.611C79.5569 85.7045 71.5909 98.9463 59.6768 106.423C65.5814 114.145 74.8751 119.176 85.3568 119.176H220.151C230.632 119.176 239.926 114.145 245.831 106.423Z" fill="#FFD5A4"/>
<path d="M37.1399 107.716C57.6141 107.716 74.2799 91.0507 74.2799 70.6115C74.2799 70.4368 74.2799 70.297 74.2799 70.1223H34.834C33.4015 70.1223 32.2136 68.9344 32.2136 67.5019C32.2136 66.0694 33.4015 64.8815 34.834 64.8815H270.671C272.103 64.8815 273.291 66.0694 273.291 67.5019C273.291 68.9344 272.103 70.1223 270.671 70.1223H231.225C231.225 70.1223 231.225 70.4368 231.225 70.6115C231.225 91.0856 247.891 107.716 268.365 107.716C288.839 107.716 305.505 91.0507 305.505 70.6115C305.505 50.1723 288.839 33.4716 268.365 33.4716H37.1399C16.6658 33.4716 0 50.1374 0 70.6115C0 91.0856 16.6658 107.716 37.1399 107.716Z" fill="#FFD5A4"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -1,3 +0,0 @@
<svg width="92" height="73" viewBox="0 0 92 73" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M34.7556 0C34.3767 0 34.0134 0.15234 33.7439 0.42188C33.4782 0.69141 33.3259 1.05469 33.3298 1.43358V5.24998H24.6189C21.2712 5.24998 18.5251 7.99608 18.5251 11.3477V21.1758C15.5368 34.2268 9.5993 46.4138 1.1541 56.8008C1.14629 56.8086 1.13848 56.8203 1.13457 56.832L0.736131 57.3477C-1.09197 59.7305 0.704881 63.3593 3.70883 63.3593H18.5248V65.2343C18.5248 69.1445 21.7201 72.3438 25.6303 72.3438H84.3183C88.2285 72.3438 91.4277 69.1445 91.4277 65.2343V21.4574V21.3988V19.3246V19.2894V11.348C91.4277 7.9964 88.6816 5.2542 85.33 5.2542H76.6308V1.4339C76.6308 1.05499 76.4824 0.68781 76.2129 0.4183C75.9433 0.14877 75.5761 0.000329971 75.1973 0.000329971C74.8184 0.000329971 74.4551 0.15267 74.1895 0.42221C73.9199 0.69174 73.7715 1.05502 73.7715 1.43391V5.25421L36.1895 5.2503V1.4339C36.1895 1.05499 36.0411 0.687814 35.7715 0.418303C35.502 0.148773 35.1344 0 34.7556 0ZM24.6186 8.1094H33.3295V11.9375C33.3334 12.7266 33.9701 13.3594 34.7553 13.3633C35.1342 13.3633 35.4975 13.2149 35.767 12.9492C36.0365 12.6797 36.1888 12.3164 36.1888 11.9375V8.10944H73.7748V11.9375H73.7709C73.7748 12.7227 74.4115 13.3594 75.1967 13.3633C75.5756 13.3633 75.9389 13.2149 76.2084 12.9493C76.478 12.6798 76.6264 12.3165 76.6303 11.9376V8.10948H85.3295C87.1342 8.10948 88.5678 9.53918 88.5678 11.3478V19.965H21.3798V19.3634V19.2892V11.3478C21.3798 9.5431 22.8139 8.1133 24.6186 8.1094ZM21.0874 22.8204H88.1774C85.0797 35.2464 79.2985 46.8514 71.2084 56.8004C71.1966 56.8082 71.1888 56.8199 71.1771 56.8317L70.7865 57.3473C69.2631 59.3356 66.9076 60.5035 64.4037 60.5035H19.9627C19.9588 60.4996 19.951 60.4996 19.9471 60.4996C19.9432 60.4996 19.9353 60.4996 19.9314 60.5035H3.70844C2.88813 60.5035 2.51314 59.7379 3.00922 59.0894L3.37641 58.605L3.38813 58.5855C11.8881 48.1285 17.9354 35.9104 21.0874 22.8204ZM88.5644 31.5274V65.2344C88.5644 67.6016 86.6816 69.4961 84.3144 69.4961H25.6304C23.2632 69.4961 21.3804 67.6016 21.3804 65.2344V63.3594H64.4074C67.798 63.3594 70.9933 61.7774 73.0558 59.086L73.423 58.6016C80.0127 50.5 85.1264 41.3354 88.5644 31.5274Z" fill="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -1,3 +0,0 @@
<svg width="88" height="69" viewBox="0 0 88 69" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M33.4525 0C33.0878 0 32.7382 0.145299 32.4788 0.40238C32.2231 0.659453 32.0765 1.00594 32.0802 1.36732V5.00732H23.6959C20.4737 5.00732 17.8306 7.6265 17.8306 10.8232V20.197C14.9543 32.6448 9.23941 44.2685 1.11083 54.1754C1.10331 54.1829 1.09579 54.1941 1.09203 54.2052L0.708533 54.697C-1.05103 56.9697 0.678454 60.4309 3.56978 60.4309H17.8303V62.2192C17.8303 65.9487 20.9058 69 24.6694 69H81.1571C84.9207 69 88 65.9487 88 62.2192V20.4656V20.4097V18.4314V18.3978V10.8235C88 7.6268 85.3568 5.01135 82.1309 5.01135H73.7579V1.36762C73.7579 1.00623 73.615 0.656019 73.3555 0.398966C73.0961 0.141894 72.7427 0.00031472 72.378 0.00031472C72.0133 0.00031472 71.6637 0.145613 71.408 0.402695C71.1486 0.659767 71.0057 1.00626 71.0057 1.36763V5.01136L34.8327 5.00763V1.36763C34.8327 1.00623 34.6898 0.656023 34.4304 0.398969C34.171 0.141897 33.8172 0 33.4525 0ZM23.6956 7.73458H32.0799V11.3857C32.0837 12.1383 32.6965 12.7419 33.4522 12.7456C33.817 12.7456 34.1666 12.6041 34.426 12.3507C34.6854 12.0937 34.8321 11.7472 34.8321 11.3858V7.73462H71.0089V11.3858H71.0052C71.0089 12.1347 71.6218 12.742 72.3775 12.7457C72.7422 12.7457 73.0919 12.6041 73.3513 12.3508C73.6107 12.0937 73.7536 11.7472 73.7573 11.3858V7.73466H82.1304C83.8674 7.73466 85.2473 9.09828 85.2473 10.8233V19.0422H20.5783V18.4684V18.3977V10.8233C20.5783 9.10202 21.9585 7.7383 23.6956 7.73458ZM20.2968 21.7656H84.8715C81.8899 33.6173 76.3255 44.6859 68.5387 54.1751C68.5274 54.1825 68.5199 54.1937 68.5086 54.2049L68.1326 54.6967C66.6663 56.5931 64.3991 57.707 61.9891 57.707H19.2143C19.2105 57.7033 19.203 57.7033 19.1992 57.7033C19.1955 57.7033 19.1879 57.7033 19.1842 57.707H3.56941C2.77985 57.707 2.41892 56.9768 2.8964 56.3582L3.24983 55.8963L3.26111 55.8776C11.4424 45.904 17.2629 34.2506 20.2968 21.7656ZM85.244 30.0702V62.2192C85.244 64.477 83.4318 66.284 81.1533 66.284H24.6695C22.391 66.284 20.5788 64.477 20.5788 62.2192V60.4309H61.9927C65.2561 60.4309 68.3316 58.922 70.3168 56.355L70.6702 55.893C77.013 48.1659 81.9349 39.4249 85.244 30.0702Z" fill="#FFE9C8"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -1,3 +0,0 @@
<svg width="89" height="70" viewBox="0 0 89 70" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M72.8799 0.25C73.3087 0.25 73.7257 0.416994 74.0332 0.72168C74.3407 1.02634 74.5097 1.44034 74.5098 1.86719V5.26172H82.6328C85.9944 5.26172 88.7518 7.98625 88.752 11.3232V62.7188C88.752 66.5884 85.5587 69.75 81.6592 69.75H25.1709C21.2714 69.7497 18.082 66.5881 18.082 62.7188V61.1807H4.07129C0.977827 61.1803 -0.877206 57.485 1.0127 55.0439L1.01367 55.043L1.38574 54.5645C1.39618 54.5468 1.40769 54.5308 1.41992 54.5166L2.17188 53.5859C9.87157 43.9211 15.3033 32.6784 18.083 20.665V11.3232C18.083 7.98644 20.84 5.25781 24.1982 5.25781H32.332V1.86719C32.3283 1.43709 32.5035 1.02844 32.8037 0.726562L32.8047 0.724609C33.1103 0.421755 33.523 0.250102 33.9541 0.25C34.383 0.25 34.801 0.417052 35.1084 0.72168C35.4158 1.02632 35.5848 1.44043 35.585 1.86719V5.25781L71.2578 5.26074V1.86719C71.2579 1.44034 71.4264 1.03058 71.7324 0.726562L71.8516 0.618164C72.1394 0.381566 72.503 0.250021 72.8799 0.25ZM85.4961 31.9873C82.1901 40.8638 77.4261 49.1646 71.3701 56.5439L71.3711 56.5449L71.0176 57.0068L71.0166 57.0078C68.9839 59.6362 65.8347 61.1807 62.4941 61.1807H21.3311V62.7197C21.3313 64.838 23.0304 66.5342 25.1719 66.5342H81.6553C83.7967 66.5342 85.4958 64.838 85.4961 62.7197V31.9873ZM20.9941 22.5156C17.9401 34.9588 12.1246 46.5722 3.96777 56.5205L3.96582 56.5254L3.95898 56.5371L3.9502 56.5479L3.59668 57.0098V57.0107C3.40752 57.2558 3.40294 57.4916 3.4834 57.6533C3.56401 57.8154 3.75623 57.957 4.07129 57.957H19.6533C19.6787 57.9529 19.7005 57.9531 19.7012 57.9531C19.7016 57.9531 19.7235 57.9529 19.749 57.957H62.4912C64.8238 57.957 67.0177 56.8788 68.4365 55.0439L68.8115 54.5527L68.8223 54.5391L68.835 54.5273L68.8359 54.5264C68.8373 54.5249 68.8394 54.5221 68.8418 54.5195C68.8444 54.5167 68.8488 54.5135 68.8535 54.5088C76.5454 45.133 82.0603 34.211 85.0508 22.5156H20.9941ZM23.9014 8.5C22.4425 8.64669 21.3301 9.84168 21.3301 11.3232V19.292H85.499V11.3232C85.499 9.73882 84.2339 8.48461 82.6328 8.48438H74.5098V11.8887C74.5053 12.3144 74.3363 12.724 74.0293 13.0283L74.0283 13.0293C73.7219 13.3286 73.3091 13.496 72.8799 13.4961H72.8779C71.9873 13.4915 71.2613 12.7747 71.2568 11.8867L71.2559 11.6357H71.2607V8.48438H35.584V11.8857C35.584 12.3152 35.4093 12.7253 35.1035 13.0283L35.1025 13.0293C34.796 13.3287 34.3835 13.4961 33.9541 13.4961H33.9531C33.0628 13.4917 32.3365 12.779 32.332 11.8867V8.48438H24.1982L23.9014 8.5Z" fill="#BD2D2D" stroke="#FFD5A4" stroke-width="0.5"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.4 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.1 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.1 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100px" height="100px"><path d="M 17 21 A 1.0001 1.0001 0 0 0 16 22 L 16 32 A 1.0001 1.0001 0 0 0 17 33 L 83 33 A 1.0001 1.0001 0 0 0 84 32 L 84 22 A 1.0001 1.0001 0 0 0 83 21 L 17 21 z M 18 23 L 82 23 L 82 31 L 18 31 L 18 29 L 73.5 29 A 0.50005 0.50005 0 1 0 73.5 28 L 18 28 L 18 23 z M 76.5 28 A 0.50005 0.50005 0 1 0 76.5 29 L 78.5 29 A 0.50005 0.50005 0 1 0 78.5 28 L 76.5 28 z M 17 44 A 1.0001 1.0001 0 0 0 16 45 L 16 55 A 1.0001 1.0001 0 0 0 17 56 L 83 56 A 1.0001 1.0001 0 0 0 84 55 L 84 45 A 1.0001 1.0001 0 0 0 83 44 L 17 44 z M 18 46 L 82 46 L 82 54 L 18 54 L 18 46 z M 22.5 51 A 0.50005 0.50005 0 1 0 22.5 52 L 67.5 52 A 0.50005 0.50005 0 1 0 67.5 51 L 22.5 51 z M 70.5 51 A 0.50005 0.50005 0 1 0 70.5 52 L 73.5 52 A 0.50005 0.50005 0 1 0 73.5 51 L 70.5 51 z M 76.5 51 A 0.50005 0.50005 0 1 0 76.5 52 L 78.5 52 A 0.50005 0.50005 0 1 0 78.5 51 L 76.5 51 z M 17 67 A 1.0001 1.0001 0 0 0 16 68 L 16 78 A 1.0001 1.0001 0 0 0 17 79 L 83 79 A 1.0001 1.0001 0 0 0 84 78 L 84 68 A 1.0001 1.0001 0 0 0 83 67 L 17 67 z M 18 69 L 82 69 L 82 74 L 26.5 74 A 0.50005 0.50005 0 1 0 26.5 75 L 82 75 L 82 77 L 18 77 L 18 69 z M 21.5 74 A 0.50005 0.50005 0 1 0 21.5 75 L 23.5 75 A 0.50005 0.50005 0 1 0 23.5 74 L 21.5 74 z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1,4 +0,0 @@
<svg width="24" height="27" viewBox="0 0 24 27" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.5 8.95076V25.6174H23.5V8.95076L12 0.617432L0.5 8.95076Z" stroke="#000000"/>
<path d="M15.8357 25.6171V15.2004H8.16907V25.6171" stroke="#000000"/>
</svg>

Before

Width:  |  Height:  |  Size: 261 B

View File

@@ -1,4 +0,0 @@
<svg width="24" height="27" viewBox="0 0 24 27" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.5 8.95076V25.6174H23.5V8.95076L12 0.617432L0.5 8.95076Z" stroke="#FFE9C8"/>
<path d="M15.8357 25.6171V15.2004H8.16907V25.6171" stroke="#FFE9C8"/>
</svg>

Before

Width:  |  Height:  |  Size: 261 B

View File

@@ -1 +0,0 @@
<svg fill="none" height="27" viewBox="0 0 24 27" width="24" xmlns="http://www.w3.org/2000/svg"><path d="m.5 8.95076v16.66664h23v-16.66664l-11.5-8.333328z" fill="#74000a" stroke="#ffe9c8"/><path d="m15.8357 25.6171v-10.4167h-7.66663v10.4167" fill="#74000a"/><path d="m15.8357 25.6171v-10.4167h-7.66663v10.4167" stroke="#ffe9c8"/></svg>

Before

Width:  |  Height:  |  Size: 334 B

View File

@@ -1,3 +0,0 @@
<svg width="100" height="90" viewBox="0 0 100 90" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M93.3636 15.8877H73.7455V11.2591C73.7455 5.05435 68.7 0 62.5 0H37.2909C31.0909 0 26.0455 5.04529 26.0455 11.2591V15.8877H6.63636C2.98182 15.8877 0 18.8678 0 22.5272V41.2772C0 43.4783 1.46364 45.3895 3.57273 46.0054V83.3696C3.57273 87.029 6.54545 90 10.1909 90H89.8091C93.4636 90 96.4273 87.029 96.4273 83.3696V46.0054C98.5364 45.3895 100 43.4783 100 41.2772V22.5272C100 18.8678 97.0273 15.8877 93.3636 15.8877ZM93.7 83.3696C93.7 85.5254 91.9545 87.2826 89.8091 87.2826H10.1909C8.04545 87.2826 6.3 85.5254 6.3 83.3696V46.712L43.7364 56.2138V60.933C43.7364 63.1522 45.5455 64.9547 47.7636 64.9547H52.0364C54.2545 64.9547 56.0546 63.1522 56.0546 60.933V56.2772L93.7 46.712V83.3696ZM52.0273 50.1993C52.7455 50.1993 53.3182 50.788 53.3182 51.5036V60.933C53.3182 61.6576 52.7364 62.2373 52.0273 62.2373H47.7545C47.0364 62.2373 46.4545 61.6485 46.4545 60.933V51.5036C46.4545 50.779 47.0364 50.1993 47.7545 50.1993H52.0273ZM97.2636 41.2772C97.2636 42.2917 96.5727 43.1793 95.5818 43.433C55.2455 53.6775 56.0455 53.5688 56.0455 53.4783V51.5127C56.0455 49.2935 54.2364 47.4909 52.0273 47.4909H47.7545C45.5636 47.4909 43.7273 49.2482 43.7273 51.5127V53.4149L4.40909 43.433C3.41818 43.1793 2.72727 42.3007 2.72727 41.2772V22.5272C2.72727 20.3623 4.48182 18.6051 6.63636 18.6051H93.3636C95.5182 18.6051 97.2727 20.3623 97.2727 22.5272V41.2772H97.2636ZM28.7727 11.25C28.7727 6.53986 32.5909 2.70833 37.2909 2.70833H62.5C67.2 2.70833 71.0182 6.53986 71.0182 11.25V15.8786H66.3182V11.25C66.3182 9.13949 64.6 7.41848 62.5 7.41848H37.2909C35.1818 7.41848 33.4727 9.13949 33.4727 11.25V15.8786H28.7727V11.25ZM36.2 15.8786V11.25C36.2 10.6341 36.6909 10.1359 37.2909 10.1359H62.5C63.1 10.1359 63.5909 10.6341 63.5909 11.25V15.8786H36.1909H36.2Z" fill="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -1,3 +0,0 @@
<svg width="110" height="100" viewBox="0 0 110 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M102.7 17.54H81.12V12.43C81.12 5.58 75.57 0 68.75 0H41.02C34.2 0 28.65 5.57 28.65 12.43V17.54H7.3C3.28 17.54 0 20.83 0 24.87V45.57C0 48 1.61 50.11 3.93 50.79V92.04C3.93 96.08 7.2 99.36 11.21 99.36H98.79C102.81 99.36 106.07 96.08 106.07 92.04V50.79C108.39 50.11 110 48 110 45.57V24.87C110 20.83 106.73 17.54 102.7 17.54ZM103.07 92.04C103.07 94.42 101.15 96.36 98.79 96.36H11.21C8.85 96.36 6.93 94.42 6.93 92.04V51.57L48.11 62.06V67.27C48.11 69.72 50.1 71.71 52.54 71.71H57.24C59.68 71.71 61.66 69.72 61.66 67.27V62.13L103.07 51.57V92.04ZM57.23 55.42C58.02 55.42 58.65 56.07 58.65 56.86V67.27C58.65 68.07 58.01 68.71 57.23 68.71H52.53C51.74 68.71 51.1 68.06 51.1 67.27V56.86C51.1 56.06 51.74 55.42 52.53 55.42H57.23ZM106.99 45.57C106.99 46.69 106.23 47.67 105.14 47.95C60.77 59.26 61.65 59.14 61.65 59.04V56.87C61.65 54.42 59.66 52.43 57.23 52.43H52.53C50.12 52.43 48.1 54.37 48.1 56.87V58.97L4.85 47.95C3.76 47.67 3 46.7 3 45.57V24.87C3 22.48 4.93 20.54 7.3 20.54H102.7C105.07 20.54 107 22.48 107 24.87V45.57H106.99ZM31.65 12.42C31.65 7.22 35.85 2.99 41.02 2.99H68.75C73.92 2.99 78.12 7.22 78.12 12.42V17.53H72.95V12.42C72.95 10.09 71.06 8.19 68.75 8.19H41.02C38.7 8.19 36.82 10.09 36.82 12.42V17.53H31.65V12.42ZM39.82 17.53V12.42C39.82 11.74 40.36 11.19 41.02 11.19H68.75C69.41 11.19 69.95 11.74 69.95 12.42V17.53H39.81H39.82Z" fill="#FFE9C8"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -1,3 +0,0 @@
<svg width="102" height="92" viewBox="0 0 102 92" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M63.5 0.5C69.9767 0.5 75.245 5.77871 75.2451 12.2588V16.3877H94.3633C98.3036 16.3877 101.5 19.5923 101.5 23.5273V42.2773C101.5 44.5771 100.051 46.5844 97.9277 47.3652V84.3691C97.9277 88.3036 94.741 91.4997 90.8096 91.5H11.1904C7.26853 91.4997 4.07227 88.304 4.07227 84.3691V47.3652C1.94941 46.5844 0.500071 44.5771 0.5 42.2773V23.5273C0.5 19.5918 3.70596 16.3877 7.63672 16.3877H26.5459V12.2588C26.546 5.76939 31.8146 0.5 38.291 0.5H63.5ZM7.7998 84.3691C7.7998 86.2515 9.32416 87.782 11.1904 87.7822H90.8096C92.6758 87.782 94.2002 86.2515 94.2002 84.3691V48.3545L57.5547 57.665V61.9326C57.5547 64.4275 55.5309 66.4551 53.0361 66.4551H48.7637C46.2703 66.4551 44.2363 64.4289 44.2363 61.9326V57.6025L7.7998 48.3535V84.3691ZM48.7549 51.6992C48.3134 51.6992 47.9541 52.0548 47.9541 52.5039V61.9326C47.9541 62.3749 48.3157 62.7373 48.7549 62.7373H53.0273C53.4574 62.7373 53.8184 62.3839 53.8184 61.9326V52.5039C53.8184 52.0571 53.462 51.6993 53.0273 51.6992H48.7549ZM7.63672 20.1055C5.75962 20.1055 4.22754 21.6373 4.22754 23.5273V42.2773C4.22761 43.0695 4.76073 43.7505 5.5332 43.9482H5.53223L44.2275 53.7725V52.5127C44.2275 49.9652 46.2947 47.9912 48.7549 47.9912H53.0273C55.5123 47.9912 57.5459 50.0171 57.5459 52.5127V53.8721C58.1385 53.7243 59.2979 53.4278 61.5635 52.8467C66.4059 51.6045 76.2902 49.0704 96.458 43.9482L96.5996 43.9062C97.2939 43.665 97.7636 43.0127 97.7637 42.2773V41.7773H97.7725V23.5273C97.7725 21.6373 96.2404 20.1055 94.3633 20.1055H7.63672ZM38.291 4.20801C33.8686 4.20801 30.2725 7.81459 30.2725 12.25V16.3789H33.9727V12.25C33.9727 9.8655 35.9036 7.91895 38.291 7.91895H63.5C65.8775 7.91895 67.8184 9.86473 67.8184 12.25V16.3789H71.5186V12.25C71.5186 7.81459 67.9225 4.20801 63.5 4.20801H38.291ZM38.291 11.6357C37.9761 11.6357 37.7002 11.9012 37.7002 12.25V16.3789H64.0908V12.25C64.0908 11.9012 63.8149 11.6357 63.5 11.6357H38.291Z" fill="#BD2D2D" stroke="#FFE9C8"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="100" height="100">
<path d="M0 0 C33 0 66 0 100 0 C100 23.76 100 47.52 100 72 C67 72 34 72 0 72 C0 48.24 0 24.48 0 0 Z M4 4 C3.46383745 8.70053019 3.46383745 8.70053019 5.30664062 10.5546875 C5.91701172 10.99039062 6.52738281 11.42609375 7.15625 11.875 C7.84114502 12.37870117 8.52604004 12.88240234 9.23168945 13.40136719 C9.98023193 13.92891602 10.72877441 14.45646484 11.5 15 C13.06813799 16.14868129 14.63450237 17.29978719 16.19921875 18.453125 C17.40070557 19.33194336 17.40070557 19.33194336 18.62646484 20.22851562 C22.28176453 22.95664302 25.79456149 25.84835522 29.3125 28.75 C31.2591282 30.33484221 33.20813973 31.91671841 35.15722656 33.49853516 C36.36876709 34.48568066 37.57657813 35.4774244 38.78027344 36.47412109 C40.40268905 37.81286849 42.04179632 39.1275644 43.6875 40.4375 C44.59886719 41.17871094 45.51023438 41.91992188 46.44921875 42.68359375 C48.88630843 44.26660079 48.88630843 44.26660079 51.37890625 43.5859375 C54.95082046 41.42468986 58.06328957 38.87595616 61.25 36.1875 C67.05660886 31.37582668 72.93368091 26.68843648 78.9375 22.125 C80.0061731 21.30745239 80.0061731 21.30745239 81.09643555 20.47338867 C84.06177854 18.20933035 87.03509444 15.97490703 90.0703125 13.8046875 C91.30394531 12.91136719 91.30394531 12.91136719 92.5625 12 C93.24441406 11.525625 93.92632812 11.05125 94.62890625 10.5625 C96.54964019 8.72009187 96.54964019 8.72009187 96 4 C65.64 4 35.28 4 4 4 Z M4 15 C4 32.49 4 49.98 4 68 C34.36 68 64.72 68 96 68 C96 50.51 96 33.02 96 15 C91.10723979 18.66957016 86.23777639 22.33985116 81.4375 26.125 C80.5556604 26.81843506 80.5556604 26.81843506 79.65600586 27.52587891 C76.42735841 30.06945531 73.21156659 32.62826175 70.0078125 35.203125 C69.01890015 35.99759033 69.01890015 35.99759033 68.01000977 36.80810547 C66.75119369 37.82090246 65.49376977 38.83543328 64.23803711 39.85205078 C63.07629198 40.7874235 61.90867537 41.71555794 60.73486328 42.63574219 C59.54616335 43.57050931 58.37318371 44.52551784 57.21435547 45.49707031 C54.26370419 47.84496996 52.75924576 48.97890984 48.90625 49.0859375 C45.41094599 47.77989648 43.28715117 46.10681719 40.5 43.625 C39.44697905 42.70771873 38.39222174 41.79242782 37.3359375 40.87890625 C36.7903418 40.40340332 36.24474609 39.92790039 35.68261719 39.43798828 C32.89861809 37.05874355 30.01440497 34.81161154 27.125 32.5625 C26.54911133 32.11293945 25.97322266 31.66337891 25.37988281 31.20019531 C24.19857561 30.27805578 23.01693136 29.35634787 21.83496094 28.43505859 C19.90812651 26.92814897 17.98846374 25.41247563 16.0703125 23.89453125 C15.43077637 23.38913818 14.79124023 22.88374512 14.13232422 22.36303711 C12.90121746 21.38980809 11.671035 20.41540842 10.44189453 19.43969727 C7.37045638 16.99238729 7.37045638 16.99238729 4 15 Z " fill="#000000" transform="translate(0,14)"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -1,7 +0,0 @@
<svg width="118" height="108" viewBox="0 0 118 108" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M80.1955 59.9449H61.5784V107.52H80.1955V59.9449Z" fill="black"/>
<path d="M31.5063 59.9449H22.1965V107.52H31.5063V59.9449Z" fill="black"/>
<path d="M97.7112 0.0305315L97.7323 0H19.6963L19.7104 0.0305315C8.77192 0.356983 0 9.30504 0 20.3245C0 30.9295 8.12254 39.6251 18.4844 40.5563C22.3596 40.5458 31.0622 39.4924 31.4051 30.569C31.6188 24.9959 27.2599 20.2905 21.6879 20.0779L21.9063 14.3545C26.1361 14.5154 30.0476 16.3132 32.9223 19.418C35.7969 22.5216 37.2918 26.56 37.1298 30.7874C36.8068 39.1871 31.0998 44.8343 22.1987 46.0402V54.2168H95.2346V46.1436C91.7576 45.7373 88.6704 44.6558 86.1727 42.9272C82.1683 40.1618 79.9489 35.9801 79.7528 30.8332C79.5896 26.6058 81.0845 22.5686 83.9626 19.4661C86.835 16.3602 90.7477 14.5623 94.9751 14.4026L95.1947 20.1273C92.4962 20.2283 89.9997 21.3767 88.1642 23.3589C86.33 25.3411 85.3765 27.9175 85.4787 30.616C85.6043 33.9345 86.9348 36.4909 89.4313 38.2183C91.6249 39.7343 94.7203 40.5528 98.1985 40.5951C108.909 40.0232 117.429 31.1808 117.429 20.3245C117.429 9.30387 108.654 0.35346 97.7112 0.0305315Z" fill="black"/>
<path d="M95.2336 59.9449H85.9238V107.52H95.2336V59.9449Z" fill="black"/>
<path d="M55.8515 59.9449H37.2344V107.52H55.8515V59.9449Z" fill="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1,7 +0,0 @@
<svg width="118" height="108" viewBox="0 0 118 108" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M80.1955 59.9449H61.5784V107.52H80.1955V59.9449Z" fill="#FFE9C8"/>
<path d="M31.5063 59.9449H22.1965V107.52H31.5063V59.9449Z" fill="#FFE9C8"/>
<path d="M97.7112 0.0305315L97.7323 0H19.6963L19.7104 0.0305315C8.77192 0.356983 0 9.30504 0 20.3245C0 30.9295 8.12254 39.6251 18.4844 40.5563C22.3596 40.5458 31.0622 39.4924 31.4051 30.569C31.6188 24.9959 27.2599 20.2905 21.6879 20.0779L21.9063 14.3545C26.1361 14.5154 30.0476 16.3132 32.9223 19.418C35.7969 22.5216 37.2918 26.56 37.1298 30.7874C36.8068 39.1871 31.0998 44.8343 22.1987 46.0402V54.2168H95.2346V46.1436C91.7576 45.7373 88.6704 44.6558 86.1727 42.9272C82.1683 40.1618 79.9489 35.9801 79.7528 30.8332C79.5896 26.6058 81.0845 22.5686 83.9626 19.4661C86.835 16.3602 90.7477 14.5623 94.9751 14.4026L95.1947 20.1273C92.4962 20.2283 89.9997 21.3767 88.1642 23.3589C86.33 25.3411 85.3765 27.9175 85.4787 30.616C85.6043 33.9345 86.9348 36.4909 89.4313 38.2183C91.6249 39.7343 94.7203 40.5528 98.1985 40.5951C108.909 40.0232 117.429 31.1808 117.429 20.3245C117.429 9.30387 108.654 0.35346 97.7112 0.0305315Z" fill="#FFE9C8"/>
<path d="M95.2336 59.9449H85.9238V107.52H95.2336V59.9449Z" fill="#FFE9C8"/>
<path d="M55.8515 59.9449H37.2344V107.52H55.8515V59.9449Z" fill="#FFE9C8"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

Some files were not shown because too many files have changed in this diff Show More