Files
sky_phone/frontend/src/views/apps/CrewLinkApp.vue
T
smx.pusha 5a35dabf78 FIX - polish CrewLink authentication UI
Align authentication fields and the animated mode switch, constrain profile images to their square avatar surface, center the ping badge, and keep regular browser fixtures authenticated. The issues came from competing shared control styles, replaced-image sizing inside the avatar grid, and mismatched badge dimensions.
2026-08-18 07:57:47 +02:00

3961 lines
108 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import {
SkyBadge,
SkyBlockTitle,
SkyButton,
SkyCard,
SkyDialog,
SkyDialogButton,
SkyIcon,
SkyLink,
SkyList,
SkyField,
SkyListItem,
SkyNavbar,
SkyAppPage,
SkySpinner,
SkySheet,
SkyTabBar,
SkyTabButton,
SkyNotification,
SkyToggle,
SkyToolbarPane,
} from '@/ui'
import {
AlertTriangle,
Camera,
Check,
CircleDot,
Copy,
Crown,
Crosshair,
Eye,
EyeOff,
Flag,
Info,
Images,
LocateFixed,
LogOut,
Map as MapIcon,
MapPinned,
MapPin,
Navigation,
Plus,
Radio,
RefreshCw,
Route,
Satellite,
Settings2,
Share2,
Shield,
ShieldCheck,
Sparkles,
Trash2,
UserPlus,
Users,
UserRound,
X,
Zap,
} from 'lucide-vue-next'
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
defaultCayoStyle,
defaultMainlandStyle,
defaultMapCoordinates,
defaultMapPercentToWorld,
defaultMapWorldToPercent,
type MapPoint,
} from '@/features/map/defaultMapGeometry'
import AccountLogoutDialog from '@/components/account/AccountLogoutDialog.vue'
import AppProfileAuth from '@/components/account/AppProfileAuth.vue'
import { useAccountStore } from '@/stores/account'
import { useAppAuthStore } from '@/stores/app-auth'
import { useCrewLinkStore } from '@/stores/crewlink'
import { useEasyShareStore } from '@/stores/easyshare'
import { useMessageMediaStore } from '@/stores/messageMedia'
import { usePhoneStore } from '@/stores/phone'
import type { PhoneMedia } from '@/types/media'
import type {
CrewLinkColour,
CrewLinkMember,
CrewLinkNearbyPlayer,
CrewLinkPing,
CrewLinkPingType,
CrewLinkRole,
} from '@/types/crewlink'
import { copyText } from '@/utils/clipboard'
import { easyShareCrewLinkInviteCode } from '@/utils/easyshare'
import { consumeEscape, handleEnterAction } from '@/utils/keyboard'
import { nuiCall } from '@/utils/nui'
import { isTrustedRootMessageSource } from '@/utils/windowMessages'
type CrewLinkTab = 'map' | 'group' | 'pings' | 'profile'
type CrewLinkMapStyle = 'default' | 'satellite' | 'atlas' | 'roads'
type CrewLinkSheet =
| 'create-group'
| 'join-group'
| 'nearby'
| 'ping'
| 'edit-group'
| 'edit-profile'
| 'member'
| null
type AuthMediaContext = {
mode: 'login' | 'register'
selectedPhoto: PhoneMedia | null
username: string
}
type ProfileMediaContext = {
selectedPhoto: PhoneMedia | null
username: string
}
const phone = usePhoneStore()
const account = useAccountStore()
const appAuth = useAppAuthStore()
const crew = useCrewLinkStore()
const messageMedia = useMessageMediaStore()
const route = useRoute()
const router = useRouter()
const mapStyle = ref<CrewLinkMapStyle>('default')
const mapAspect = ref(
defaultMapCoordinates.width / defaultMapCoordinates.height,
)
const mapMaxZoom = ref(8)
const cayoMapUrl = `${import.meta.env.BASE_URL}img/maps/cayo-perico.svg`
const mapBounds = {
minX: -4096,
maxX: 4096,
minY: -4096,
maxY: 4096,
}
const mapOrigin = { x: -336.8, y: -1412.2 }
const mapScale = { x: -2340 / -1548.1, y: 291 / 189.3 }
const mapStyles = [
{ id: 'default' as const, icon: MapPinned },
{ id: 'satellite' as const, icon: Satellite },
{ id: 'atlas' as const, icon: MapIcon },
{ id: 'roads' as const, icon: Route },
]
const activeMapStyle = computed(
() => mapStyles.find((style) => style.id === mapStyle.value) ?? mapStyles[0],
)
const mapImageUrl = computed(() => {
const filename =
mapStyle.value === 'default'
? 'gtav-map.svg'
: mapStyle.value === 'roads'
? 'map_roads_4096.webp'
: mapStyle.value === 'atlas'
? 'map_atlas_4096.webp'
: 'map_satellite_4096.webp'
return `${import.meta.env.BASE_URL}img/maps/${filename}`
})
const activeTab = ref<CrewLinkTab>('map')
const headerTitle = computed(() =>
activeTab.value === 'map'
? t('name')
: t(activeTab.value === 'group' ? 'crew' : activeTab.value),
)
const headerSubtitle = computed(() =>
activeTab.value === 'map' ? t('liveCoordination') : t('name'),
)
const sheet = ref<CrewLinkSheet>(null)
const username = ref('')
const authMode = ref<'login' | 'register'>('login')
const authUsername = ref('')
const authPassword = ref('')
const authProfilePhoto = ref<PhoneMedia | null>(null)
const authPending = ref(false)
const authError = ref('')
const selectedProfilePhoto = ref<PhoneMedia | null>(null)
const profileAvatarRemoved = ref(false)
const groupName = ref('')
const groupColour = ref<CrewLinkColour>('cyan')
const inviteCode = ref('')
const sharedInviteCode = ref(
easyShareCrewLinkInviteCode(
route.query.easyShareKind,
route.query.easyShareLink,
route.query.easyShareId,
),
)
const pingType = ref<CrewLinkPingType>('meeting')
const pingLabel = ref('')
const placingPing = ref(false)
const pingCoords = ref<MapPoint | null>(null)
const nearbyPlayers = ref<CrewLinkNearbyPlayer[]>([])
const selectedMember = ref<CrewLinkMember | null>(null)
const selectedPing = ref<CrewLinkPing | null>(null)
const formError = ref('')
const logoutDialogOpen = ref(false)
const toastText = ref('')
const pendingGroupSetting = ref<'allowMemberPings' | 'overheadAllowed' | null>(
null,
)
const pendingVisibility = ref<'mapVisible' | 'overheadVisible' | null>(null)
const confirmAction = ref<
'delete-group' | 'leave-group' | 'remove-member' | 'transfer-owner' | null
>(null)
const zoom = ref(1.45)
const pan = ref<MapPoint>({ x: 0, y: 0 })
const viewportRef = ref<HTMLElement | null>(null)
const canvasRef = ref<HTMLElement | null>(null)
const isPointerDown = ref(false)
const pointerStart = { x: 0, y: 0, panX: 0, panY: 0 }
const pointerLatest = { x: 0, y: 0 }
let liveTimer: number | undefined
let toastTimer: number | undefined
let pointerFrame: number | undefined
const colours: Array<{ id: CrewLinkColour; value: string }> = [
{ id: 'cyan', value: '#27d9ed' },
{ id: 'blue', value: '#2790ff' },
{ id: 'violet', value: '#8b5cf6' },
{ id: 'orange', value: '#ff9f43' },
{ id: 'green', value: '#36d17c' },
{ id: 'rose', value: '#ff5f86' },
]
const roleIcons: Record<CrewLinkRole, typeof Crown> = {
owner: Crown,
coordinator: ShieldCheck,
moderator: Shield,
member: UserRound,
guest: Eye,
}
const pingIcons: Record<CrewLinkPingType, typeof MapPin> = {
meeting: Users,
danger: AlertTriangle,
help: Zap,
target: Crosshair,
info: Info,
}
const pingColours: Record<CrewLinkPingType, string> = {
meeting: '#27d9ed',
danger: '#ff4d67',
help: '#ffb020',
target: '#8b5cf6',
info: '#3198ff',
}
const roleLevels: Record<CrewLinkRole, number> = {
guest: 1,
member: 2,
moderator: 3,
coordinator: 4,
owner: 5,
}
const editableRoles: CrewLinkRole[] = [
'coordinator',
'moderator',
'member',
'guest',
]
const activeGroup = computed(() => crew.activeGroup)
const ownMember = computed(() =>
activeGroup.value?.members.find((member) => member.id === crew.profile?.id),
)
const onlineMembers = computed(
() => activeGroup.value?.members.filter((member) => member.online) ?? [],
)
const visibleMapMembers = computed(() =>
onlineMembers.value.filter((member) => Boolean(member.coords)),
)
const canCoordinate = computed(
() =>
roleLevels[activeGroup.value?.role ?? 'guest'] >= roleLevels.coordinator,
)
const canModerate = computed(
() => roleLevels[activeGroup.value?.role ?? 'guest'] >= roleLevels.moderator,
)
const canPing = computed(
() => canModerate.value || Boolean(activeGroup.value?.allowMemberPings),
)
const authUsernameValid = computed(() =>
/^[A-Za-z0-9][A-Za-z0-9._]{1,18}[A-Za-z0-9]$/.test(authUsername.value.trim()),
)
const canvasStyle = computed(() => ({
aspectRatio: String(mapAspect.value),
transform: `translate3d(-50%, -50%, 0) translate3d(${pan.value.x}px, ${pan.value.y}px, 0) scale(${zoom.value})`,
width: 'max(128%, 128vh)',
}))
const activeColour = computed(
() =>
colours.find((colour) => colour.id === activeGroup.value?.colour)?.value ??
colours[0].value,
)
const activeCrewStyle = computed(() => ({
'--crew': activeColour.value,
'--crew-aura': `${activeColour.value}8c`,
'--crew-glow': `${activeColour.value}61`,
'--crew-ring': `${activeColour.value}47`,
}))
const mapCenterCoords = computed(() => {
const viewport = viewportRef.value?.getBoundingClientRect()
const canvas = canvasRef.value?.getBoundingClientRect()
if (!viewport || !canvas) return null
return mapPercentToWorld({
x: Math.min(
1,
Math.max(
0,
(viewport.left + viewport.width / 2 - canvas.left) / canvas.width,
),
),
y: Math.min(
1,
Math.max(
0,
(viewport.top + viewport.height / 2 - canvas.top) / canvas.height,
),
),
})
})
function mapToWorld(coords: MapPoint): MapPoint {
return {
x: coords.x / mapScale.x + mapOrigin.x,
y: coords.y / mapScale.y + mapOrigin.y,
}
}
function mapWorldToPercent(coords: MapPoint): MapPoint {
if (mapStyle.value === 'default') return defaultMapWorldToPercent(coords)
const world = mapToWorld(coords)
return {
x: Math.min(
1,
Math.max(
0,
(world.x - mapBounds.minX) / (mapBounds.maxX - mapBounds.minX),
),
),
y: Math.min(
1,
Math.max(
0,
(mapBounds.maxY - world.y) / (mapBounds.maxY - mapBounds.minY),
),
),
}
}
function mapPercentToWorld(point: MapPoint): MapPoint {
if (mapStyle.value === 'default') return defaultMapPercentToWorld(point)
const projected = {
x: mapBounds.minX + point.x * (mapBounds.maxX - mapBounds.minX),
y: mapBounds.maxY - point.y * (mapBounds.maxY - mapBounds.minY),
}
return {
x: (projected.x - mapOrigin.x) * mapScale.x,
y: (projected.y - mapOrigin.y) * mapScale.y,
}
}
function cycleMapStyle(): void {
const currentIndex = mapStyles.findIndex(
(style) => style.id === mapStyle.value,
)
mapStyle.value = mapStyles[(currentIndex + 1) % mapStyles.length].id
}
async function onMapImageLoad(event: Event): Promise<void> {
const image = event.target as HTMLImageElement
const loadedStyle = mapStyle.value
mapAspect.value =
loadedStyle === 'default'
? defaultMapCoordinates.width / defaultMapCoordinates.height
: image.naturalWidth / image.naturalHeight
await nextTick()
if (!image.isConnected || mapStyle.value !== loadedStyle) return
mapMaxZoom.value =
loadedStyle === 'default'
? 8
: Math.max(
0.85,
Math.min(
8,
image.naturalWidth / Math.max(1, image.offsetWidth),
image.naturalHeight / Math.max(1, image.offsetHeight),
),
)
if (zoom.value > mapMaxZoom.value) setZoomAt(mapMaxZoom.value)
}
function t(path: string, replacements: Record<string, string> = {}): string {
return phone.t(`Apps.crewlink.${path}`, replacements)
}
function errorText(code?: string): string {
const key = code ?? crew.error ?? 'request_failed'
const translated = t(`errors.${key}`)
return translated === `Apps.crewlink.errors.${key}`
? t('errors.request_failed')
: translated
}
function showToast(message: string): void {
if (toastTimer) window.clearTimeout(toastTimer)
toastText.value = message
toastTimer = window.setTimeout(() => {
toastText.value = ''
}, 2400)
}
function switchAuthMode(mode: 'login' | 'register'): void {
authMode.value = mode
authProfilePhoto.value = null
authPassword.value = ''
authUsername.value =
mode === 'register'
? (account.email.split('@')[0] ?? '')
.replace(/[^a-z0-9._]/gi, '_')
.slice(0, 20)
: ''
authError.value = ''
}
function authErrorText(code?: string): string {
const known = [
'invalid_credentials',
'invalid_password',
'invalid_profile_image',
'invalid_username',
'no_ifruit_account',
'profile_exists',
'profile_not_found',
'rate_limited',
'username_taken',
]
return t(
`authErrors.${code && known.includes(code) ? code : 'request_failed'}`,
)
}
async function submitAuthentication(): Promise<void> {
authError.value = ''
if (!account.email) {
authError.value = authErrorText('no_ifruit_account')
return
}
if (authPassword.value.length < 8 || authPassword.value.length > 72) {
authError.value = authErrorText('invalid_password')
return
}
if (authMode.value === 'register' && !authUsernameValid.value) {
authError.value = authErrorText('invalid_username')
return
}
const submittedUsername = authUsername.value.trim()
authPending.value = true
const response =
authMode.value === 'login'
? await crew.login(authPassword.value)
: await crew.register(
submittedUsername,
authPassword.value,
authProfilePhoto.value?.id ?? 0,
)
authPending.value = false
if (!response.success || !crew.profile) {
authError.value = authErrorText(response.error)
return
}
appAuth.signIn('crewlink', account.email)
username.value = crew.profile?.username ?? submittedUsername
authUsername.value = ''
authPassword.value = ''
authProfilePhoto.value = null
openSharedInvite()
}
async function handleLoggedOut(): Promise<void> {
await crew.logout()
activeTab.value = 'map'
authPassword.value = ''
}
function openAuthMedia(app: 'camera' | 'photos'): void {
messageMedia.begin(
'crewlink:auth-avatar',
'photo',
'/apps/crewlink?auth=register',
1,
{
mode: authMode.value,
selectedPhoto: authProfilePhoto.value,
username: authUsername.value,
} satisfies AuthMediaContext,
)
void router.push({
path: `/apps/${app}`,
query: { mediaAttachment: 'photo' },
})
}
function shareProfile(): void {
const profile = crew.profile
if (!profile) return
useEasyShareStore().open({
appId: 'crewlink',
copyText: `@${profile.username}`,
id: profile.id,
kind: 'profile',
link: `skyphone://crewlink/profile/${profile.id}`,
subtitle: activeGroup.value?.name,
title: `@${profile.username}`,
})
}
function shareGroupInvite(): void {
const group = activeGroup.value
if (!group?.inviteCode) return
useEasyShareStore().open({
appId: 'crewlink',
copyText: t('shareInviteBody', {
code: group.inviteCode,
group: group.name,
}),
id: group.inviteCode,
kind: 'link',
link: `skyphone://crewlink/invite/${group.inviteCode}`,
subtitle: group.inviteCode,
title: t('shareInviteTitle', { group: group.name }),
})
}
function openSharedInvite(): void {
if (!sharedInviteCode.value || !crew.profile) return
openSheet('join-group')
inviteCode.value = sharedInviteCode.value
sharedInviteCode.value = null
}
function updateValue(
target: 'username' | 'groupName' | 'inviteCode' | 'pingLabel',
event: Event,
): void {
const value = (event.target as HTMLInputElement).value
if (target === 'username') username.value = value
else if (target === 'groupName') groupName.value = value
else if (target === 'inviteCode') inviteCode.value = value
else pingLabel.value = value
formError.value = ''
}
function colourValue(colour: CrewLinkColour): string {
return (
colours.find((candidate) => candidate.id === colour)?.value ??
colours[0].value
)
}
function roleLabel(role: CrewLinkRole): string {
return t(`roles.${role}`)
}
function memberInitials(member: CrewLinkMember): string {
return member.username.slice(0, 2).toUpperCase()
}
function markerStyle(
coords: MapPoint,
offset = '-50%',
): Record<string, string> {
const point = mapWorldToPercent(coords)
const canvasWidth = canvasRef.value?.offsetWidth ?? 0
const canvasHeight = canvasRef.value?.offsetHeight ?? 0
return {
left: `calc(50% + ${pan.value.x + (point.x - 0.5) * canvasWidth * zoom.value}px)`,
top: `calc(50% + ${pan.value.y + (point.y - 0.5) * canvasHeight * zoom.value}px)`,
transform: `translate(-50%, ${offset})`,
}
}
function memberStatus(member: CrewLinkMember): string {
if (!member.online) return t('status.offline')
if (!member.coords) return t('status.hidden')
return t('status.live')
}
function expiresIn(timestamp: number): string {
const seconds = Math.max(0, Math.ceil((timestamp - Date.now()) / 1000))
if (seconds >= 60)
return t('expiresMinutes', { count: String(Math.ceil(seconds / 60)) })
return t('expiresSeconds', { count: String(seconds) })
}
function openSheet(next: CrewLinkSheet): void {
sheet.value = next
formError.value = ''
if (next === 'create-group') {
groupName.value = ''
groupColour.value = 'cyan'
} else if (next === 'join-group') {
inviteCode.value = ''
} else if (next === 'ping') {
pingType.value = 'meeting'
pingLabel.value = ''
pingCoords.value = null
} else if (next === 'edit-group' && activeGroup.value) {
groupName.value = activeGroup.value.name
groupColour.value = activeGroup.value.colour
}
}
function closeSheet(): void {
if (crew.isLoading) return
if (sheet.value === 'ping') pingCoords.value = null
sheet.value = null
selectedMember.value = null
selectedProfilePhoto.value = null
profileAvatarRemoved.value = false
formError.value = ''
}
function editOwnProfile(): void {
if (!crew.profile) return
username.value = crew.profile.username
selectedProfilePhoto.value = null
profileAvatarRemoved.value = false
formError.value = ''
sheet.value = 'edit-profile'
}
function openProfileMedia(app: 'camera' | 'photos'): void {
messageMedia.begin(
'crewlink:profile-avatar',
'photo',
'/apps/crewlink?profileEdit=1',
1,
{
selectedPhoto: selectedProfilePhoto.value,
username: username.value,
} satisfies ProfileMediaContext,
)
void router.push({
path: `/apps/${app}`,
query: { mediaAttachment: 'photo' },
})
}
function removeProfilePhoto(): void {
selectedProfilePhoto.value = null
profileAvatarRemoved.value = true
}
async function createGroup(): Promise<void> {
const response = await crew.createGroup(
groupName.value.trim(),
groupColour.value,
)
if (!response.success) {
formError.value = errorText(response.error)
return
}
closeSheet()
activeTab.value = 'map'
showToast(t('groupCreated'))
}
async function joinGroup(): Promise<void> {
const response = await crew.joinCode(inviteCode.value.trim().toUpperCase())
if (!response.success) {
formError.value = errorText(response.error)
return
}
closeSheet()
activeTab.value = 'map'
showToast(t('joinedGroup'))
}
async function switchGroup(groupId: string): Promise<void> {
if (groupId === activeGroup.value?.id) return
const response = await crew.setActive(groupId)
if (!response.success) showToast(errorText(response.error))
else {
await nextTick()
fitOnlineMembers()
showToast(t('activeGroupChanged'))
}
}
async function saveProfile(): Promise<void> {
if (!crew.profile) return
const avatarMediaId =
selectedProfilePhoto.value?.id ??
(profileAvatarRemoved.value ? 0 : crew.profile.avatarMediaId)
const response = await crew.updateProfile(
username.value.trim(),
crew.profile.mapVisible,
crew.profile.overheadVisible,
avatarMediaId,
)
if (!response.success) formError.value = errorText(response.error)
else {
closeSheet()
showToast(t('profileSaved'))
}
}
async function updateVisibility(
key: 'mapVisible' | 'overheadVisible',
): Promise<void> {
if (!crew.profile || pendingVisibility.value) return
const previous = crew.profile[key]
crew.profile[key] = !previous
pendingVisibility.value = key
const response = await crew.updateProfile(
crew.profile.username,
crew.profile.mapVisible,
crew.profile.overheadVisible,
)
pendingVisibility.value = null
if (!response.success) {
if (crew.profile) crew.profile[key] = previous
showToast(errorText(response.error))
}
}
async function saveGroup(): Promise<void> {
if (!activeGroup.value) return
const response = await crew.updateGroup(
activeGroup.value.id,
groupName.value.trim(),
groupColour.value,
activeGroup.value.allowMemberPings,
activeGroup.value.overheadAllowed,
)
if (!response.success) {
formError.value = errorText(response.error)
return
}
closeSheet()
showToast(t('groupSaved'))
}
async function toggleGroupSetting(
key: 'allowMemberPings' | 'overheadAllowed',
): Promise<void> {
if (!activeGroup.value || pendingGroupSetting.value) return
const groupId = activeGroup.value.id
const previous = activeGroup.value[key]
activeGroup.value[key] = !previous
pendingGroupSetting.value = key
const response = await crew.updateGroup(
groupId,
activeGroup.value.name,
activeGroup.value.colour,
activeGroup.value.allowMemberPings,
activeGroup.value.overheadAllowed,
)
pendingGroupSetting.value = null
if (!response.success) {
if (activeGroup.value?.id === groupId) activeGroup.value[key] = previous
showToast(errorText(response.error))
}
}
function startPingPlacement(): void {
sheet.value = null
formError.value = ''
pingType.value = 'meeting'
pingLabel.value = ''
pingCoords.value = null
placingPing.value = true
activeTab.value = 'map'
}
function resumePingPlacement(): void {
sheet.value = null
formError.value = ''
placingPing.value = true
activeTab.value = 'map'
}
function cancelPingPlacement(): void {
placingPing.value = false
pingCoords.value = null
}
function confirmPingPlacement(): void {
const center = mapCenterCoords.value
if (!center) {
showToast(errorText())
return
}
pingCoords.value = { x: center.x, y: center.y }
placingPing.value = false
sheet.value = 'ping'
}
function copyInviteCode(): void {
if (!activeGroup.value?.inviteCode) return
showToast(
copyText(activeGroup.value.inviteCode) ? t('codeCopied') : errorText(),
)
}
async function rotateInviteCode(): Promise<void> {
if (!activeGroup.value) return
const response = await crew.rotateCode(activeGroup.value.id)
if (!response.success) showToast(errorText(response.error))
else {
await crew.bootstrap()
showToast(t('codeRotated'))
}
}
async function loadNearby(): Promise<void> {
openSheet('nearby')
nearbyPlayers.value = []
const response = await crew.nearby()
if (!response.success) formError.value = errorText(response.error)
else nearbyPlayers.value = response.data ?? []
}
async function inviteNearby(player: CrewLinkNearbyPlayer): Promise<void> {
const response = await crew.inviteNearby(player.source)
if (!response.success) {
formError.value = errorText(response.error)
return
}
nearbyPlayers.value = nearbyPlayers.value.filter(
(candidate) => candidate.source !== player.source,
)
showToast(t('inviteSent', { username: player.username }))
}
async function respondInvitation(id: string, accepted: boolean): Promise<void> {
const response = await crew.respondInvite(id, accepted)
if (!response.success) showToast(errorText(response.error))
else {
if (accepted) {
closeSheet()
activeTab.value = 'map'
}
showToast(t(accepted ? 'inviteAccepted' : 'inviteDeclined'))
}
}
function selectMember(member: CrewLinkMember): void {
if (member.id === crew.profile?.id) return
selectedMember.value = member
openSheet('member')
}
async function setMemberRole(role: CrewLinkRole): Promise<void> {
if (!activeGroup.value || !selectedMember.value) return
if (selectedMember.value.role === role) return
const response = await crew.updateMember(
activeGroup.value.id,
selectedMember.value.id,
role,
)
if (!response.success) showToast(errorText(response.error))
else {
closeSheet()
showToast(t('roleUpdated'))
}
}
async function performConfirmedAction(): Promise<void> {
const action = confirmAction.value
confirmAction.value = null
if (!activeGroup.value) return
let response
if (action === 'delete-group')
response = await crew.deleteGroup(activeGroup.value.id)
else if (action === 'leave-group')
response = await crew.leave(activeGroup.value.id)
else if (action === 'remove-member' && selectedMember.value) {
response = await crew.removeMember(
activeGroup.value.id,
selectedMember.value.id,
)
} else if (action === 'transfer-owner' && selectedMember.value) {
response = await crew.transferOwner(
activeGroup.value.id,
selectedMember.value.id,
)
}
if (!response?.success) showToast(errorText(response?.error))
else {
closeSheet()
await crew.bootstrap()
showToast(t(`${action}Done`))
}
}
function cancelConfirmation(): void {
confirmAction.value = null
}
async function createPing(): Promise<void> {
if (!pingCoords.value) {
formError.value = errorText()
return
}
const response = await crew.createPing(
pingType.value,
pingLabel.value.trim(),
{ x: pingCoords.value.x, y: pingCoords.value.y, z: 0 },
)
if (!response.success) {
formError.value = errorText(response.error)
return
}
closeSheet()
activeTab.value = 'map'
await crew.refreshLive()
showToast(t('pingCreated'))
}
async function removePing(ping: CrewLinkPing): Promise<void> {
const response = await crew.removePing(ping.id)
if (!response.success) showToast(errorText(response.error))
else {
selectedPing.value = null
await crew.refreshLive()
showToast(t('pingRemoved'))
}
}
async function routeTo(coords: {
x: number
y: number
z: number
}): Promise<void> {
const response = await nuiCall('map:setWaypoint', { coords })
showToast(t(response.success ? 'routeSet' : 'errors.request_failed'))
}
function routeToSelectedMember(): void {
if (!selectedMember.value?.coords) return
void routeTo(selectedMember.value.coords)
selectedMember.value = null
}
function routeToSelectedPing(): void {
if (!selectedPing.value) return
void routeTo(selectedPing.value.coords)
selectedPing.value = null
}
function centerOn(coords: MapPoint): void {
const viewport = viewportRef.value?.getBoundingClientRect()
const canvas = canvasRef.value
if (!viewport || !canvas) return
const point = mapWorldToPercent(coords)
const nextZoom = Math.min(3.8, mapMaxZoom.value)
zoom.value = nextZoom
pan.value = {
x: (0.5 - point.x) * canvas.offsetWidth * nextZoom,
y: (0.5 - point.y) * canvas.offsetHeight * nextZoom,
}
}
function fitOnlineMembers(): void {
const viewport = viewportRef.value
const canvas = canvasRef.value
const points = visibleMapMembers.value.map((member) =>
mapWorldToPercent(member.coords!),
)
if (!viewport || !canvas || !points.length) return
const minX = Math.min(...points.map((point) => point.x))
const maxX = Math.max(...points.map((point) => point.x))
const minY = Math.min(...points.map((point) => point.y))
const maxY = Math.max(...points.map((point) => point.y))
const horizontalSpan = Math.max(80, (maxX - minX) * canvas.offsetWidth)
const verticalSpan = Math.max(80, (maxY - minY) * canvas.offsetHeight)
const nextZoom = Math.max(
1.25,
Math.min(
mapMaxZoom.value,
(viewport.clientWidth - 90) / horizontalSpan,
(viewport.clientHeight - 120) / verticalSpan,
),
)
const center = {
x: (minX + maxX) / 2,
y: (minY + maxY) / 2,
}
zoom.value = nextZoom
pan.value = {
x: (0.5 - center.x) * canvas.offsetWidth * nextZoom,
y: (0.5 - center.y) * canvas.offsetHeight * nextZoom,
}
}
function centerOwnLocation(): void {
const own = ownMember.value?.coords
if (own) centerOn(own)
else showToast(t('locationUnavailable'))
}
function setZoomAt(nextZoom: number, clientX?: number, clientY?: number): void {
const viewport = viewportRef.value?.getBoundingClientRect()
const currentZoom = zoom.value
const clampedZoom = Math.max(0.85, Math.min(mapMaxZoom.value, nextZoom))
if (!viewport || clampedZoom === currentZoom) return
const anchorX = clientX ?? viewport.left + viewport.width / 2
const anchorY = clientY ?? viewport.top + viewport.height / 2
const offsetX = anchorX - (viewport.left + viewport.width / 2)
const offsetY = anchorY - (viewport.top + viewport.height / 2)
const scale = clampedZoom / currentZoom
pan.value = {
x: offsetX - (offsetX - pan.value.x) * scale,
y: offsetY - (offsetY - pan.value.y) * scale,
}
zoom.value = clampedZoom
}
function changeZoom(direction: -1 | 1): void {
setZoomAt(zoom.value * (direction > 0 ? 1.25 : 0.8))
}
function onPointerDown(event: PointerEvent): void {
if (event.pointerType === 'mouse' && event.button !== 0) return
isPointerDown.value = true
pointerStart.x = event.clientX
pointerStart.y = event.clientY
pointerStart.panX = pan.value.x
pointerStart.panY = pan.value.y
pointerLatest.x = event.clientX
pointerLatest.y = event.clientY
;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
}
function onPointerMove(event: PointerEvent): void {
if (!isPointerDown.value) return
pointerLatest.x = event.clientX
pointerLatest.y = event.clientY
if (pointerFrame) return
pointerFrame = requestAnimationFrame(() => {
pointerFrame = undefined
pan.value = {
x: pointerStart.panX + pointerLatest.x - pointerStart.x,
y: pointerStart.panY + pointerLatest.y - pointerStart.y,
}
})
}
function onPointerUp(event: PointerEvent): void {
isPointerDown.value = false
const target = event.currentTarget as HTMLElement
if (target.hasPointerCapture(event.pointerId)) {
target.releasePointerCapture(event.pointerId)
}
}
function onWheel(event: WheelEvent): void {
event.preventDefault()
const viewportHeight = viewportRef.value?.clientHeight ?? 1
const pixelDelta =
event.deltaMode === 1
? event.deltaY * 16
: event.deltaMode === 2
? event.deltaY * viewportHeight
: event.deltaY
const limitedDelta = Math.max(-120, Math.min(120, pixelDelta))
setZoomAt(
zoom.value * Math.exp(-limitedDelta * 0.0018),
event.clientX,
event.clientY,
)
}
function onCrewLinkMessage(event: MessageEvent): void {
if (!isTrustedRootMessageSource(event.source, window)) return
if (event.data?.type === 'crewlink:changed') void crew.bootstrap()
}
function onKeydown(event: KeyboardEvent): void {
if (
!confirmAction.value &&
!sheet.value &&
!selectedMember.value &&
!selectedPing.value &&
!placingPing.value
) {
return
}
if (!consumeEscape(event)) return
if (confirmAction.value) {
cancelConfirmation()
} else if (sheet.value) {
closeSheet()
} else if (selectedMember.value) {
selectedMember.value = null
} else if (selectedPing.value) {
selectedPing.value = null
} else {
cancelPingPlacement()
}
}
onMounted(async () => {
window.addEventListener('keydown', onKeydown, true)
const authSelection = messageMedia.consumeMany<AuthMediaContext>(
'crewlink:auth-avatar',
)
const profileSelection = messageMedia.consumeMany<ProfileMediaContext>(
'crewlink:profile-avatar',
)
if (authSelection) {
authMode.value = authSelection.context?.mode ?? 'register'
authUsername.value = authSelection.context?.username ?? ''
authProfilePhoto.value =
authSelection.media[0] ?? authSelection.context?.selectedPhoto ?? null
}
if (appAuth.isSignedIn('crewlink')) {
await crew.bootstrap()
if (!crew.authenticated) appAuth.signOut('crewlink')
}
username.value = crew.profile?.username ?? ''
if (profileSelection && crew.profile) {
username.value = profileSelection.context?.username ?? crew.profile.username
selectedProfilePhoto.value =
profileSelection.media[0] ??
profileSelection.context?.selectedPhoto ??
null
profileAvatarRemoved.value = false
activeTab.value = 'profile'
sheet.value = 'edit-profile'
}
openSharedInvite()
await nextTick()
fitOnlineMembers()
liveTimer = window.setInterval(() => void crew.refreshLive(), 3000)
window.addEventListener('message', onCrewLinkMessage)
})
onBeforeUnmount(() => {
if (liveTimer) window.clearInterval(liveTimer)
if (toastTimer) window.clearTimeout(toastTimer)
if (pointerFrame) cancelAnimationFrame(pointerFrame)
window.removeEventListener('keydown', onKeydown, true)
window.removeEventListener('message', onCrewLinkMessage)
})
</script>
<template>
<sky-app-page
class="crewlink"
:class="{ 'crewlink--dark': phone.isDarkMode }"
>
<template v-if="!appAuth.isSignedIn('crewlink')">
<div class="crewlink-onboarding crewlink-auth">
<AppProfileAuth
:mode="authMode"
v-model:password="authPassword"
v-model:username="authUsername"
:avatar-url="authProfilePhoto?.url ?? null"
:body="t('authBody')"
:camera-label="t('camera')"
:email="account.email"
:email-label="t('ifruitEmail')"
:error="authError"
:eyebrow="t('authEyebrow')"
:gallery-label="t('gallery')"
:login-label="t('login')"
:max-username-length="20"
:min-username-length="3"
:pending="authPending"
:password-label="t('password')"
:password-placeholder="t('passwordPlaceholder')"
:register-label="t('register')"
require-password
:title="t('authTitle')"
:username-label="t('username')"
:username-placeholder="t('usernamePlaceholder')"
variant="centered"
@camera="openAuthMedia('camera')"
@gallery="openAuthMedia('photos')"
@submit="submitAuthentication"
@update:mode="switchAuthMode"
/>
</div>
</template>
<template v-else-if="crew.isLoading && !crew.profile && !crew.error">
<div class="crewlink-loading">
<span class="crewlink-logo"><Radio /></span>
<sky-spinner />
<p>{{ t('connecting') }}</p>
</div>
</template>
<template v-else-if="crew.error === 'not_authenticated'">
<div class="crewlink-onboarding">
<span class="crewlink-logo"><Users /></span>
<small>{{ t('privateNetwork') }}</small>
<h1>{{ t('signInTitle') }}</h1>
<p>{{ t('signInBody') }}</p>
<sky-button large rounded @click="appAuth.signOut('crewlink')">
{{ t('backToLogin') }}
</sky-button>
</div>
</template>
<template v-else-if="crew.profile">
<sky-navbar
v-if="activeGroup"
class="crewlink-navbar"
:class="{ 'crewlink-navbar--map': activeTab === 'map' }"
:subtitle="headerSubtitle"
:title="headerTitle"
/>
<main
class="crewlink-content"
:class="{
'crewlink-content--empty': !activeGroup,
'crewlink-content--placing': placingPing,
}"
>
<section v-if="!activeGroup" class="crewlink-group-gate">
<button type="button" @click="openSheet('create-group')">
<span><Plus /></span>
<strong>{{ t('createGroup') }}</strong>
</button>
<button type="button" @click="openSheet('join-group')">
<span><UserPlus /></span>
<strong>{{ t('joinGroup') }}</strong>
</button>
<sky-button
large
rounded
outline
class="crewlink-group-gate__logout"
@click="logoutDialogOpen = true"
>
<LogOut />{{ phone.t('Common.signOut') }}
</sky-button>
</section>
<template v-else>
<section v-show="activeTab === 'map'" class="crewlink-map-tab">
<div class="crewlink-map-summary">
<div>
<span class="crewlink-live-dot"></span>
<strong>{{ onlineMembers.length }}</strong>
<small>{{ t('onlineNow') }}</small>
</div>
<div class="crewlink-online-faces">
<span
v-for="member in onlineMembers.slice(0, 4)"
:key="member.id"
:style="{ borderColor: activeColour }"
>{{ memberInitials(member) }}</span
>
<i v-if="onlineMembers.length > 4"
>+{{ onlineMembers.length - 4 }}</i
>
</div>
</div>
<div
ref="viewportRef"
class="crewlink-map"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@pointercancel="onPointerUp"
@wheel="onWheel"
>
<div
ref="canvasRef"
class="crewlink-map__canvas"
:style="canvasStyle"
>
<img
:src="mapImageUrl"
alt=""
class="crewlink-map__mainland"
:class="{
'crewlink-map__mainland--default': mapStyle === 'default',
}"
:style="
mapStyle === 'default' ? defaultMainlandStyle : undefined
"
draggable="false"
@load="onMapImageLoad"
/>
<img
v-if="mapStyle === 'default'"
:src="cayoMapUrl"
alt=""
class="crewlink-map__cayo"
:style="defaultCayoStyle"
draggable="false"
/>
</div>
<button
v-for="member in visibleMapMembers"
:key="member.id"
type="button"
class="crewlink-member-marker"
:class="{ 'is-self': member.id === crew.profile?.id }"
:style="{
...markerStyle(member.coords!),
...activeCrewStyle,
}"
@pointerdown.stop
@click.stop="selectedMember = member"
>
<span>
<img v-if="member.avatarUrl" :src="member.avatarUrl" alt="" />
<template v-else>{{ memberInitials(member) }}</template>
</span>
<small>{{ member.username }}</small>
</button>
<button
v-for="ping in activeGroup.pings"
:key="ping.id"
type="button"
class="crewlink-ping-marker"
:style="{
...markerStyle(ping.coords, '-100%'),
'--ping': pingColours[ping.type],
}"
@pointerdown.stop
@click.stop="selectedPing = ping"
>
<component :is="pingIcons[ping.type]" />
<small>{{ ping.label }}</small>
</button>
<div v-if="placingPing" class="crewlink-map-crosshair">
<Crosshair />
</div>
<div class="crewlink-map-controls">
<button
type="button"
:aria-label="t('zoomIn')"
@pointerdown.stop
@click="changeZoom(1)"
>
+
</button>
<button
type="button"
:aria-label="t('zoomOut')"
@pointerdown.stop
@click="changeZoom(-1)"
>
</button>
<button
type="button"
:aria-label="t('myLocation')"
@pointerdown.stop
@click="centerOwnLocation"
>
<LocateFixed />
</button>
<button
type="button"
:aria-label="`${t('members')} · ${t('map')}`"
@pointerdown.stop
@click="fitOnlineMembers"
>
<Users />
</button>
<button
type="button"
:aria-label="`${phone.t('Apps.map.switchStyle')}: ${phone.t(`Apps.map.styles.${mapStyle}`)}`"
@pointerdown.stop
@click="cycleMapStyle"
>
<component :is="activeMapStyle.icon" />
</button>
</div>
<div v-if="!placingPing" class="crewlink-map-legend">
<span><i class="is-live"></i>{{ t('status.live') }}</span>
<span><i class="is-hidden"></i>{{ t('status.hidden') }}</span>
</div>
</div>
<div v-if="placingPing" class="crewlink-ping-placement">
<div>
<strong>{{ t('placeOnMap') }}</strong>
<small>{{ t('placeOnMapBody') }}</small>
</div>
<div>
<sky-button rounded outline @click="cancelPingPlacement">
{{ phone.t('Common.cancel') }}
</sky-button>
<sky-button rounded @click="confirmPingPlacement">
<MapPin />{{ t('addHere') }}
</sky-button>
</div>
</div>
<div v-else class="crewlink-map-actions">
<button type="button" @click="activeTab = 'group'">
<Users /><span
><strong
>{{ activeGroup.memberCount }}
{{
t(activeGroup.memberCount === 1 ? 'member' : 'members')
}}</strong
><small>{{ t('openCrew') }}</small></span
>
</button>
<button
type="button"
:disabled="!canPing"
@click="startPingPlacement"
>
<MapPin /><span
><strong>{{ t('newPing') }}</strong
><small>{{ t('shareLocation') }}</small></span
>
</button>
</div>
</section>
<section
v-show="activeTab === 'group'"
class="crewlink-scroll-tab crewlink-group-tab"
>
<div class="crewlink-group-hero" :style="activeCrewStyle">
<div class="crewlink-group-hero__signal"><Radio /></div>
<small>{{ t('activeCrew') }}</small>
<h1>{{ activeGroup.name }}</h1>
<p>
{{
t(
activeGroup.memberCount === 1
? 'groupSummarySingle'
: 'groupSummary',
{
online: String(onlineMembers.length),
total: String(activeGroup.memberCount),
},
)
}}
</p>
<div>
<sky-badge class="crewlink-group-badge">{{
roleLabel(activeGroup.role)
}}</sky-badge>
<sky-badge class="crewlink-group-badge">{{
t('private')
}}</sky-badge>
</div>
</div>
<div class="crewlink-quick-actions">
<button v-if="canModerate" type="button" @click="loadNearby">
<UserPlus /><span>{{ t('nearby') }}</span>
</button>
<button
v-if="activeGroup.inviteCode"
type="button"
@click="copyInviteCode"
>
<Copy /><span>{{ t('copyCode') }}</span>
</button>
<button
v-if="activeGroup.inviteCode"
type="button"
@click="shareGroupInvite"
>
<Share2 /><span>{{ t('shareInvite') }}</span>
</button>
<button
v-if="canCoordinate"
type="button"
@click="openSheet('edit-group')"
>
<Settings2 /><span>{{ t('manage') }}</span>
</button>
</div>
<template v-if="crew.invitations.length">
<sky-block-title>{{ t('pendingInvitations') }}</sky-block-title>
<div class="crewlink-invitations crewlink-invitations--inline">
<sky-card v-for="invite in crew.invitations" :key="invite.id">
<div class="crewlink-invite-card">
<i :style="{ background: colourValue(invite.colour) }"
><Users
/></i>
<div>
<strong>{{ invite.groupName }}</strong
><span>{{
t('invitedBy', { username: invite.inviterUsername })
}}</span>
</div>
<button
type="button"
@click="respondInvitation(invite.id, false)"
>
<X />
</button>
<button
type="button"
class="is-accept"
@click="respondInvitation(invite.id, true)"
>
<Check />
</button>
</div>
</sky-card>
</div>
</template>
<sky-block-title class="crewlink-members-title">{{
t(activeGroup.memberCount === 1 ? 'member' : 'members')
}}</sky-block-title>
<sky-list inset strong class="crewlink-member-list">
<sky-list-item
v-for="member in activeGroup.members"
:key="member.id"
:link="member.id !== crew.profile?.id && canModerate"
@click="selectMember(member)"
>
<template #title
><span class="crewlink-member-title">{{
member.username
}}</span></template
>
<template #subtitle
><span class="crewlink-member-subtitle"
>{{ roleLabel(member.role) }} ·
{{ memberStatus(member) }}</span
></template
>
<template #media>
<span
class="crewlink-avatar"
:style="{ '--crew': activeColour }"
>
<img
v-if="member.avatarUrl"
:src="member.avatarUrl"
alt=""
/>
<template v-else>{{ memberInitials(member) }}</template>
<i :class="{ 'is-online': member.online }"></i>
</span>
</template>
<template #after>
<component
:is="roleIcons[member.role]"
:size="17"
:class="`role-${member.role}`"
/>
</template>
</sky-list-item>
</sky-list>
</section>
<section
v-show="activeTab === 'pings'"
class="crewlink-scroll-tab crewlink-pings-tab"
>
<div class="crewlink-section-header">
<span><MapPin /></span>
<div>
<small>{{ t('liveCoordination') }}</small>
<h1>{{ t('pings') }}</h1>
</div>
<button v-if="canPing" type="button" @click="startPingPlacement">
<Plus />
</button>
</div>
<div v-if="activeGroup.pings.length" class="crewlink-ping-list">
<sky-card
v-for="ping in activeGroup.pings"
:key="ping.id"
:content-wrap="false"
>
<article>
<i :style="{ background: pingColours[ping.type] }"
><component :is="pingIcons[ping.type]"
/></i>
<div>
<small
>{{ t(`pingTypes.${ping.type}`) }} ·
{{ expiresIn(ping.expiresAt) }}</small
><strong>{{ ping.label }}</strong
><span>{{
t('sharedBy', { username: ping.creatorUsername })
}}</span>
</div>
<button
type="button"
:aria-label="t('setRoute')"
@click="routeTo(ping.coords)"
>
<Navigation />
</button>
<button
v-if="
ping.creatorProfileId === crew.profile?.id || canModerate
"
type="button"
:aria-label="phone.t('Common.delete')"
@click="removePing(ping)"
>
<Trash2 />
</button>
</article>
</sky-card>
</div>
<div v-else class="crewlink-empty-state">
<span><CircleDot /></span>
<h2>{{ t('noPings') }}</h2>
<p>{{ t('noPingsBody') }}</p>
</div>
</section>
<section
v-show="activeTab === 'profile'"
class="crewlink-scroll-tab crewlink-profile-tab"
>
<div
class="crewlink-profile-card"
:style="{ '--crew': activeColour }"
>
<span>
<img
v-if="crew.profile.avatarUrl"
:src="crew.profile.avatarUrl"
alt=""
/>
<template v-else>{{
crew.profile.username.slice(0, 2).toUpperCase()
}}</template>
</span>
<div>
<small>{{ t('yourCrewLinkId') }}</small>
<h1>@{{ crew.profile.username }}</h1>
<p>
{{ activeGroup.name }} · {{ roleLabel(activeGroup.role) }}
</p>
</div>
</div>
<sky-list inset strong>
<sky-list-item
:title="t('editProfile')"
:subtitle="t('editProfileBody')"
link
@click="editOwnProfile"
>
<template #media>
<span class="crewlink-profile-row-avatar">
<img
v-if="crew.profile.avatarUrl"
:src="crew.profile.avatarUrl"
alt=""
/>
<UserRound v-else :size="20" />
</span>
</template>
</sky-list-item>
<sky-list-item
link
link-component="button"
:title="phone.t('Apps.easyShare.shareProfile')"
@click="shareProfile"
>
<template #media><Share2 :size="20" /></template>
</sky-list-item>
</sky-list>
<sky-block-title>{{ t('privacyVisibility') }}</sky-block-title>
<sky-list inset strong>
<sky-list-item
:title="t('shareOnMap')"
:subtitle="t('shareOnMapBody')"
>
<template #media><MapIcon :size="20" /></template>
<template #after
><sky-toggle
:checked="crew.profile.mapVisible"
:disabled="pendingVisibility !== null"
@click.stop.prevent="updateVisibility('mapVisible')"
/></template>
</sky-list-item>
<sky-list-item
:title="t('overheadLabels')"
:subtitle="t('overheadLabelsBody')"
>
<template #media><Eye :size="20" /></template>
<template #after
><sky-toggle
:checked="crew.profile.overheadVisible"
:disabled="pendingVisibility !== null"
@click.stop.prevent="updateVisibility('overheadVisible')"
/></template>
</sky-list-item>
</sky-list>
<sky-block-title>{{ t('yourGroups') }}</sky-block-title>
<sky-list inset strong>
<sky-list-item
v-for="group in crew.groups"
:key="group.id"
:title="group.name"
:subtitle="`${group.memberCount} ${t('members')} · ${roleLabel(group.role)}`"
link
@click="switchGroup(group.id)"
>
<template #media
><span
class="crewlink-group-dot"
:style="{ background: colourValue(group.colour) }"
><Users /></span
></template>
<template #after
><Check
v-if="group.id === activeGroup.id"
:size="18"
:style="{ color: colourValue(group.colour) }"
/></template>
</sky-list-item>
<sky-list-item
:title="t('createAnotherGroup')"
link
@click="openSheet('create-group')"
><template #media><Plus :size="20" /></template
></sky-list-item>
<sky-list-item
:title="t('joinWithCode')"
link
@click="openSheet('join-group')"
><template #media><UserPlus :size="20" /></template
></sky-list-item>
</sky-list>
<sky-block-title>{{ t('account') }}</sky-block-title>
<sky-list inset strong>
<sky-list-item
:title="t('externalApi')"
:subtitle="t('externalApiBody')"
><template #media><Sparkles :size="20" /></template
></sky-list-item>
<sky-list-item
:title="phone.t('Common.signOut')"
class="crewlink-danger-row"
link
@click="logoutDialogOpen = true"
>
<template #media><LogOut :size="20" /></template>
</sky-list-item>
<sky-list-item
:title="
activeGroup.isOwner ? t('deleteGroup') : t('leaveGroup')
"
class="crewlink-danger-row"
link
@click="
confirmAction = activeGroup.isOwner
? 'delete-group'
: 'leave-group'
"
>
<template #media><Trash2 :size="20" /></template>
</sky-list-item>
</sky-list>
</section>
</template>
</main>
<sky-tab-bar
v-if="activeGroup && !placingPing"
component="nav"
icons
labels
class="bottom-0 left-0 fixed crewlink-tabbar"
inner-class="crewlink-tabbar__inner"
:aria-label="t('navigation')"
>
<sky-toolbar-pane class="crewlink-tabbar__pane">
<sky-tab-button
component="button"
:active="activeTab === 'map'"
:link-props="{ type: 'button' }"
@click="activeTab = 'map'"
><template #label>{{ t('map') }}</template
><template #icon
><sky-icon><MapIcon /></sky-icon></template
></sky-tab-button>
<sky-tab-button
component="button"
:active="activeTab === 'group'"
:link-props="{ type: 'button' }"
@click="activeTab = 'group'"
><template #label>{{ t('crew') }}</template
><template #icon
><sky-icon><Users /></sky-icon></template
></sky-tab-button>
<sky-tab-button
component="button"
:active="activeTab === 'pings'"
:link-props="{ type: 'button' }"
@click="activeTab = 'pings'"
>
<template #label>{{ t('pings') }}</template>
<template #icon>
<sky-icon>
<span class="crewlink-pings-icon">
<MapPin />
<sky-badge
v-if="activeGroup.pings.length"
small
class="crewlink-pings-badge"
>{{ activeGroup.pings.length }}</sky-badge
>
</span>
</sky-icon>
</template>
</sky-tab-button>
<sky-tab-button
component="button"
:active="activeTab === 'profile'"
:link-props="{ type: 'button' }"
@click="activeTab = 'profile'"
><template #label>{{ t('profile') }}</template
><template #icon
><sky-icon><UserRound /></sky-icon></template
></sky-tab-button>
</sky-toolbar-pane>
</sky-tab-bar>
</template>
<sky-sheet :opened="Boolean(sheet)" @backdropclick="closeSheet">
<section
v-if="sheet"
class="crewlink-sheet__panel__content"
:class="{
'crewlink-sheet__panel__content--manage': sheet === 'edit-group',
'crewlink-sheet__panel__content--ping': sheet === 'ping',
}"
role="dialog"
aria-modal="true"
>
<sky-link
component="button"
class="crewlink-sheet__panel__close"
:link-props="{ type: 'button' }"
:aria-label="phone.t('Common.close')"
@click="closeSheet"
><X
/></sky-link>
<template v-if="sheet === 'create-group'">
<span class="crewlink-sheet__panel__icon"><Users /></span>
<h2>{{ t('createGroup') }}</h2>
<p>{{ t('createGroupBody') }}</p>
<sky-list inset strong class="crewlink-form-list"
><sky-field
input-id="crewlink-group-name"
:label="t('groupName')"
:placeholder="t('groupNamePlaceholder')"
:value="groupName"
maxlength="32"
outline
@input="updateValue('groupName', $event)"
/></sky-list>
<span class="crewlink-field-label">{{ t('groupColour') }}</span>
<div class="crewlink-colours" role="radiogroup">
<button
v-for="colour in colours"
:key="colour.id"
type="button"
role="radio"
:aria-checked="groupColour === colour.id"
:class="{ 'is-active': groupColour === colour.id }"
:style="{ background: colour.value }"
@click="groupColour = colour.id"
>
<Check />
</button>
</div>
<p v-if="formError" class="crewlink-error">{{ formError }}</p>
<sky-button
large
rounded
:disabled="crew.isLoading"
@click="createGroup"
>{{ t('createCrew') }}</sky-button
>
</template>
<template v-else-if="sheet === 'join-group'">
<span class="crewlink-sheet__panel__icon"><UserPlus /></span>
<h2>{{ t('joinWithCode') }}</h2>
<p>{{ t('joinWithCodeBody') }}</p>
<sky-list inset strong class="crewlink-form-list"
><sky-field
input-id="crewlink-invite-code"
:label="t('inviteCode')"
:placeholder="t('inviteCodePlaceholder')"
:value="inviteCode"
maxlength="8"
outline
@input="updateValue('inviteCode', $event)"
@keydown.enter="handleEnterAction($event, joinGroup)"
/></sky-list>
<p v-if="formError" class="crewlink-error">{{ formError }}</p>
<sky-button
large
rounded
:disabled="crew.isLoading"
@click="joinGroup"
>{{ t('joinCrew') }}</sky-button
>
<template v-if="crew.invitations.length">
<sky-block-title class="crewlink-join-invitations-title">{{
t('pendingInvitations')
}}</sky-block-title>
<div class="crewlink-invitations crewlink-invitations--join">
<sky-card v-for="invite in crew.invitations" :key="invite.id">
<div class="crewlink-invite-card">
<i :style="{ background: colourValue(invite.colour) }"
><Users
/></i>
<div>
<strong>{{ invite.groupName }}</strong
><span>{{
t('invitedBy', { username: invite.inviterUsername })
}}</span>
</div>
<button
type="button"
@click="respondInvitation(invite.id, false)"
>
<X />
</button>
<button
type="button"
class="is-accept"
@click="respondInvitation(invite.id, true)"
>
<Check />
</button>
</div>
</sky-card>
</div>
</template>
</template>
<template v-else-if="sheet === 'nearby'">
<span class="crewlink-sheet__panel__icon"><Radio /></span>
<h2>{{ t('peopleNearby') }}</h2>
<p>
{{
t('peopleNearbyBody', {
distance: String(crew.limits?.nearbyDistance ?? 5),
})
}}
</p>
<sky-spinner v-if="crew.isLoading" />
<sky-list
v-else-if="nearbyPlayers.length"
inset
strong
class="crewlink-nearby-list"
>
<sky-list-item
v-for="player in nearbyPlayers"
:key="player.source"
class="crewlink-nearby-item"
content-class="crewlink-nearby-item__content"
media-class="crewlink-nearby-item__media"
inner-class="crewlink-nearby-item__inner"
title-wrap-class="crewlink-nearby-item__title"
:title="player.username"
:subtitle="t('metersAway', { distance: String(player.distance) })"
>
<template #media
><span
class="crewlink-avatar"
:style="{ '--crew': activeColour }"
>{{ player.username.slice(0, 2).toUpperCase() }}</span
></template
>
<template #after
><sky-button
small
rounded
class="crewlink-nearby-invite"
@click="inviteNearby(player)"
>{{ t('invite') }}</sky-button
></template
>
</sky-list-item>
</sky-list>
<div v-else class="crewlink-sheet__panel-empty">
<EyeOff /><strong>{{ t('nobodyNearby') }}</strong
><span>{{ t('nobodyNearbyBody') }}</span>
</div>
<p v-if="formError" class="crewlink-error">{{ formError }}</p>
<sky-button
large
rounded
outline
class="crewlink-nearby-rescan"
@click="loadNearby"
><RefreshCw />{{ t('scanAgain') }}</sky-button
>
</template>
<template v-else-if="sheet === 'ping'">
<span class="crewlink-sheet__panel__icon"><MapPin /></span>
<h2 class="crewlink-ping-title">{{ t('newPing') }}</h2>
<p class="crewlink-ping-description">{{ t('newPingBody') }}</p>
<div class="crewlink-ping-types">
<button
v-for="(_, type) in pingIcons"
:key="type"
type="button"
:class="{ 'is-active': pingType === type }"
:style="{
'--ping': pingColours[type],
'--ping-glow': `${pingColours[type]}2e`,
}"
@click="pingType = type"
>
<component :is="pingIcons[type]" /><span>{{
t(`pingTypes.${type}`)
}}</span>
</button>
</div>
<sky-list inset strong class="crewlink-form-list crewlink-ping-form"
><sky-field
input-id="crewlink-ping-label"
input-class="crewlink-ping-label-input"
:label="t('pingLabel')"
:placeholder="t('pingLabelPlaceholder')"
:value="pingLabel"
maxlength="48"
outline
@input="updateValue('pingLabel', $event)"
/></sky-list>
<sky-list inset strong class="crewlink-ping-location-list">
<sky-list-item
:title="t('placeOnMap')"
:subtitle="t('positionSelected')"
link
@click="resumePingPlacement"
>
<template #media><Crosshair :size="22" /></template>
<template #after><MapPinned :size="19" /></template>
</sky-list-item>
</sky-list>
<p v-if="formError" class="crewlink-error">{{ formError }}</p>
<sky-button
large
rounded
class="crewlink-share-ping"
:disabled="crew.isLoading"
@click="createPing"
><Flag />{{ t('sharePing') }}</sky-button
>
</template>
<template v-else-if="sheet === 'edit-group' && activeGroup">
<span class="crewlink-sheet__panel__icon"><Settings2 /></span>
<h2>{{ t('manageCrew') }}</h2>
<p>{{ t('manageCrewBody') }}</p>
<sky-list inset strong class="crewlink-form-list"
><sky-field
input-id="crewlink-edit-name"
:label="t('groupName')"
:value="groupName"
maxlength="32"
outline
@input="updateValue('groupName', $event)"
/></sky-list>
<span class="crewlink-field-label">{{ t('groupColour') }}</span>
<div class="crewlink-colours">
<button
v-for="colour in colours"
:key="colour.id"
type="button"
:class="{ 'is-active': groupColour === colour.id }"
:style="{ background: colour.value }"
@click="groupColour = colour.id"
>
<Check />
</button>
</div>
<sky-list inset strong>
<sky-list-item
:title="t('memberPings')"
:subtitle="t('memberPingsBody')"
><template #after
><sky-toggle
:checked="activeGroup.allowMemberPings"
:disabled="pendingGroupSetting !== null"
@click.stop.prevent="
toggleGroupSetting('allowMemberPings')
" /></template
></sky-list-item>
<sky-list-item
:title="t('allowOverhead')"
:subtitle="t('allowOverheadBody')"
><template #after
><sky-toggle
:checked="activeGroup.overheadAllowed"
:disabled="pendingGroupSetting !== null"
@click.stop.prevent="
toggleGroupSetting('overheadAllowed')
" /></template
></sky-list-item>
</sky-list>
<sky-card v-if="activeGroup.inviteCode"
><div class="crewlink-code-card">
<small>{{ t('inviteCode') }}</small
><strong>{{ activeGroup.inviteCode }}</strong
><button type="button" @click="copyInviteCode"><Copy /></button
><button type="button" @click="rotateInviteCode">
<RefreshCw />
</button></div
></sky-card>
<p v-if="formError" class="crewlink-error">{{ formError }}</p>
<sky-button large rounded @click="saveGroup">{{
phone.t('Common.save')
}}</sky-button>
</template>
<template v-else-if="sheet === 'member' && selectedMember">
<span
class="crewlink-sheet__panel__avatar"
:style="{ '--crew': activeColour }"
>{{ memberInitials(selectedMember) }}</span
>
<h2>@{{ selectedMember.username }}</h2>
<p>
{{ roleLabel(selectedMember.role) }} ·
{{ memberStatus(selectedMember) }}
</p>
<sky-block-title class="crewlink-role-title">{{
t('assignRole')
}}</sky-block-title>
<sky-list inset strong class="crewlink-role-list">
<sky-list-item
v-for="role in editableRoles"
:key="role"
class="crewlink-role-item"
content-class="crewlink-role-item__content"
media-class="crewlink-role-item__media"
inner-class="crewlink-role-item__inner"
title-wrap-class="crewlink-role-item__title"
:title="roleLabel(role)"
:subtitle="t(`roleDescriptions.${role}`)"
:link="role !== selectedMember.role"
@click="setMemberRole(role)"
>
<template #media
><component :is="roleIcons[role]" :size="20"
/></template>
<template #after
><Check v-if="role === selectedMember.role" :size="18"
/></template>
</sky-list-item>
</sky-list>
<div class="crewlink-member-actions">
<sky-button
v-if="activeGroup?.isOwner"
large
rounded
outline
@click="confirmAction = 'transfer-owner'"
><Crown />{{ t('transferOwnership') }}</sky-button
>
<sky-button
large
rounded
tonal
class="crewlink-danger-button"
@click="confirmAction = 'remove-member'"
><Trash2 />{{ t('removeMember') }}</sky-button
>
</div>
</template>
<template v-else-if="sheet === 'edit-profile' && crew.profile">
<div class="crewlink-profile-editor__avatar">
<img
v-if="selectedProfilePhoto?.url"
:src="selectedProfilePhoto.url"
alt=""
/>
<img
v-else-if="crew.profile.avatarUrl && !profileAvatarRemoved"
:src="crew.profile.avatarUrl"
alt=""
/>
<UserRound v-else />
</div>
<h2>{{ t('editProfile') }}</h2>
<p>{{ t('editProfileBody') }}</p>
<div class="crewlink-profile-editor__media-actions">
<sky-button rounded outline @click="openProfileMedia('photos')">
<Images :size="16" />{{ t('gallery') }}
</sky-button>
<sky-button rounded outline @click="openProfileMedia('camera')">
<Camera :size="16" />{{ t('camera') }}
</sky-button>
</div>
<sky-button
v-if="
selectedProfilePhoto ||
(crew.profile.avatarUrl && !profileAvatarRemoved)
"
rounded
tonal
class="crewlink-profile-editor__remove"
@click="removeProfilePhoto"
>
<Trash2 :size="15" />{{ t('removeProfilePhoto') }}
</sky-button>
<sky-list inset strong class="crewlink-form-list"
><sky-field
input-id="crewlink-edit-username"
:label="t('username')"
:value="username"
maxlength="20"
outline
@input="updateValue('username', $event)"
@keydown.enter="handleEnterAction($event, saveProfile)"
/></sky-list>
<p v-if="formError" class="crewlink-error">{{ formError }}</p>
<sky-button
large
rounded
:disabled="crew.isLoading"
@click="saveProfile"
>
<sky-spinner v-if="crew.isLoading" />
<template v-else>{{ phone.t('Common.save') }}</template>
</sky-button>
</template>
</section>
</sky-sheet>
<sky-sheet
:opened="Boolean(selectedMember && !sheet)"
@backdropclick="selectedMember = null"
>
<section
v-if="selectedMember"
class="crewlink-sheet__panel__content crewlink-member-preview"
>
<sky-link
component="button"
class="crewlink-sheet__panel__close"
:link-props="{ type: 'button' }"
@click="selectedMember = null"
><X
/></sky-link>
<span
class="crewlink-sheet__panel__avatar"
:style="{ '--crew': activeColour }"
>
<img
v-if="selectedMember.avatarUrl"
:src="selectedMember.avatarUrl"
alt=""
/>
<template v-else>{{ memberInitials(selectedMember) }}</template>
</span>
<h2>@{{ selectedMember.username }}</h2>
<p>
{{ roleLabel(selectedMember.role) }} ·
{{ memberStatus(selectedMember) }}
</p>
<sky-button
v-if="selectedMember.coords"
large
rounded
@click="routeToSelectedMember"
><Route />{{ t('setRoute') }}</sky-button
>
</section>
</sky-sheet>
<sky-sheet
:opened="Boolean(selectedPing)"
@backdropclick="selectedPing = null"
>
<section
v-if="selectedPing"
class="crewlink-sheet__panel__content crewlink-member-preview"
>
<sky-link
component="button"
class="crewlink-sheet__panel__close"
:link-props="{ type: 'button' }"
@click="selectedPing = null"
><X
/></sky-link>
<span
class="crewlink-sheet__panel__icon"
:style="{ background: pingColours[selectedPing.type] }"
><component :is="pingIcons[selectedPing.type]" /></span
><small>{{ t(`pingTypes.${selectedPing.type}`) }}</small>
<h2>{{ selectedPing.label }}</h2>
<p>
{{ t('sharedBy', { username: selectedPing.creatorUsername }) }} ·
{{ expiresIn(selectedPing.expiresAt) }}
</p>
<sky-button large rounded @click="routeToSelectedPing"
><Navigation />{{ t('setRoute') }}</sky-button
>
</section>
</sky-sheet>
<AccountLogoutDialog
v-model:opened="logoutDialogOpen"
app-id="crewlink"
:app-name="t('name')"
@logged-out="handleLoggedOut"
/>
<sky-dialog
:opened="Boolean(confirmAction)"
@backdropclick="cancelConfirmation"
>
<template #title>{{ t(`confirm.${confirmAction}.title`) }}</template>
<p>{{ t(`confirm.${confirmAction}.body`) }}</p>
<template #buttons
><sky-dialog-button
class="crewlink-dialog-cancel"
type="button"
:aria-label="phone.t('Common.cancel')"
@click="cancelConfirmation"
>{{ phone.t('Common.cancel') }}</sky-dialog-button
><sky-dialog-button
strong
type="button"
@click="performConfirmedAction"
>{{ t('confirmAction') }}</sky-dialog-button
></template
>
</sky-dialog>
<sky-notification :opened="Boolean(toastText)" :text="toastText" />
</sky-app-page>
</template>
<style scoped>
.crewlink {
--cl-bg: #f2f6fa;
--cl-surface: rgba(255, 255, 255, 0.84);
--cl-text: #102034;
--cl-muted: #6e7c8d;
--cl-map-header-background: linear-gradient(90deg, #061823, #0f2837);
--sky-safe-area-top: 46px;
--sky-safe-area-bottom: 25px;
position: relative;
height: 100%;
background: var(--cl-bg);
color: var(--cl-text);
overflow: hidden;
}
.crewlink--dark {
--cl-bg: #071018;
--cl-surface: rgba(17, 29, 40, 0.88);
--cl-text: #f3f8fb;
--cl-muted: #8fa2b3;
}
.crewlink :deep(.page-content) {
background: transparent;
}
.crewlink-navbar {
--sky-safe-area-top: 46px;
--sky-navbar-glass: var(--cl-bg);
position: absolute;
z-index: 20;
top: 0;
right: 0;
left: 0;
}
.crewlink-content {
position: absolute;
inset: 0;
overflow: hidden;
}
.crewlink-content--empty {
inset: var(--sky-safe-area-top) 0 var(--sky-safe-area-bottom);
}
.crewlink-scroll-tab {
height: 100%;
overflow-y: auto;
padding: 108px 11px 112px;
}
.crewlink-loading,
.crewlink-onboarding {
height: 100%;
padding: 52px 30px 32px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: 12px;
background: radial-gradient(
circle at 50% 32%,
rgba(39, 217, 237, 0.18),
transparent 36%
);
}
.crewlink-auth {
--auth-accent: #20bde0;
--panel: rgba(18, 39, 53, 0.92);
box-sizing: border-box;
min-height: 100%;
padding: calc(var(--sky-safe-area-top) + 20px) 18px
calc(var(--sky-safe-area-bottom) + 20px);
justify-content: center;
color: #f3f8fb;
background:
radial-gradient(circle at 82% 8%, rgba(39, 217, 237, 0.2), transparent 34%),
linear-gradient(180deg, #07131d, #091923);
overflow-y: auto;
}
.crewlink-auth :deep(.app-profile-auth) {
width: min(100%, 340px);
flex: none;
margin: auto;
}
.crewlink-auth :deep(.app-profile-auth__mode) {
position: relative;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 4px;
min-height: 44px;
padding: 4px;
overflow: hidden;
border: 1px solid rgba(132, 173, 192, 0.18);
border-radius: var(--sky-radius-pill, 999px);
background: rgba(5, 20, 29, 0.62);
box-shadow: none;
}
.crewlink-auth :deep(.app-profile-auth__mode::before) {
position: absolute;
z-index: 0;
top: 4px;
bottom: 4px;
left: 4px;
width: calc((100% - 12px) / 2);
border-radius: var(--sky-radius-pill, 999px);
background: linear-gradient(135deg, #139de9, #1687f5);
box-shadow: 0 5px 14px rgba(13, 126, 226, 0.26);
content: '';
pointer-events: none;
transition: transform 220ms cubic-bezier(0.22, 1, 0.36, 1);
}
.crewlink-auth :deep(.app-profile-auth__mode--register::before) {
transform: translateX(calc(100% + 4px));
}
.crewlink-auth :deep(.app-profile-auth__mode-button) {
position: relative;
z-index: 1;
min-width: 0;
min-height: 34px;
border-radius: var(--sky-radius-pill, 999px) !important;
color: var(--auth-accent);
background: transparent;
box-shadow: none;
transition:
color 160ms ease,
background-color 160ms ease,
box-shadow 160ms ease,
transform 100ms ease;
}
.crewlink-auth :deep(.app-profile-auth__mode-button--active) {
color: #ffffff;
background: transparent !important;
box-shadow: none;
}
.crewlink-auth :deep(.app-profile-auth__mode-button:active) {
transform: scale(0.98);
}
@media (prefers-reduced-motion: reduce) {
.crewlink-auth :deep(.app-profile-auth__mode::before),
.crewlink-auth :deep(.app-profile-auth__mode-button) {
transition: none;
}
}
.crewlink-auth :deep(.app-profile-auth__fields) {
--sky-list-outer-left: 0px;
--sky-list-outer-right: 0px;
margin: 0 0 11px;
padding: 12px;
border: 1px solid rgba(132, 173, 192, 0.1);
border-radius: 22px;
background: rgba(13, 35, 47, 0.74) !important;
}
.crewlink-auth :deep(.app-profile-auth__fields > .sky-list__items) {
display: grid;
gap: 11px;
}
.crewlink-auth :deep(.app-profile-auth__credential-field) {
min-height: 56px;
margin: 0;
padding: 0 14px;
border: 1px solid rgba(145, 178, 194, 0.52);
border-radius: 11px;
color: #f3f8fb;
background: rgba(8, 25, 35, 0.32);
transition:
border-color 160ms ease,
background-color 160ms ease,
box-shadow 160ms ease;
}
.crewlink-auth :deep(.app-profile-auth__credential-field:focus-within) {
border-color: var(--auth-accent);
background: rgba(11, 34, 45, 0.72);
box-shadow: 0 0 0 2px rgba(32, 189, 224, 0.14);
}
.crewlink-auth :deep(.app-profile-auth__credential-field .sky-field__border) {
display: none;
}
.crewlink-auth :deep(.app-profile-auth__credential-field .sky-field__media) {
width: 22px;
justify-content: center;
margin-right: 10px;
padding: 0;
color: #e6f4f8;
}
.crewlink-auth :deep(.app-profile-auth__credential-field .sky-field__inner) {
display: flex;
flex-direction: column;
justify-content: center;
padding: 7px 0;
}
.crewlink-auth :deep(.app-profile-auth__credential-field .sky-field__label) {
display: block;
margin: 0;
color: #8ea5b0;
font-size: 10px;
font-weight: 650;
line-height: 13px;
transform: none;
}
.crewlink-auth
:deep(.app-profile-auth__credential-field .sky-field__label-text) {
position: static;
top: auto;
margin: 0;
padding: 0;
background: transparent;
}
.crewlink-auth :deep(.app-profile-auth__credential-field .sky-field__control) {
margin: 0;
}
.crewlink-auth :deep(.app-profile-auth__credential-field .sky-field__input) {
height: 24px;
min-height: 24px;
color: #f3f8fb;
font-size: 14px;
font-weight: 650;
line-height: 20px;
}
.crewlink-auth
:deep(.app-profile-auth__credential-field .sky-field__input::placeholder) {
color: #647b86;
opacity: 1;
}
.crewlink-logo {
width: 78px;
height: 78px;
border-radius: 26px;
display: grid;
place-items: center;
color: white;
background: linear-gradient(145deg, #28dbe9, #1d88ff 58%, #7050ef);
box-shadow: 0 18px 42px rgba(20, 137, 213, 0.3);
}
.crewlink-logo svg {
width: 38px;
height: 38px;
}
.crewlink-onboarding small {
color: #168cbb;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.crewlink-onboarding h1 {
margin: 0;
font-size: 27px;
line-height: 1.05;
}
.crewlink-onboarding p {
margin: 0 0 10px;
color: var(--cl-muted);
font-size: 13px;
line-height: 1.5;
}
.crewlink-onboarding :deep(.button) {
width: 100%;
}
.crewlink-navbar--map {
--sky-text: #f3f8fb;
--sky-muted: #aab7c1;
background: var(--cl-map-header-background);
}
.crewlink-navbar--map :deep(.sky-navbar__blur),
.crewlink-navbar--map :deep(.sky-navbar__background) {
display: none;
}
.crewlink-orbits {
width: 170px;
height: 170px;
position: relative;
margin-bottom: 4px;
}
.crewlink-orbits i {
position: absolute;
inset: 8px;
border: 1px solid rgba(39, 217, 237, 0.22);
border-radius: 50%;
animation: cl-pulse 2.8s infinite;
}
.crewlink-orbits i:nth-child(2) {
inset: 31px;
animation-delay: 0.3s;
}
.crewlink-orbits i:nth-child(3) {
inset: 55px;
animation-delay: 0.6s;
}
.crewlink-orbits span {
position: absolute;
inset: 69px;
border-radius: 18px;
display: grid;
place-items: center;
color: white;
background: linear-gradient(145deg, #2ce4ed, #257cff);
box-shadow: 0 10px 35px rgba(31, 162, 228, 0.4);
}
.crewlink-form-list {
width: 100%;
margin: 4px 0;
}
.crewlink-privacy {
display: flex;
gap: 5px;
align-items: center;
color: var(--cl-muted) !important;
letter-spacing: 0 !important;
text-transform: none !important;
font-weight: 500 !important;
}
.crewlink-privacy svg {
width: 14px;
}
.crewlink-group-gate {
height: 100%;
padding: 24px;
display: grid;
grid-template-columns: 1fr 1fr;
align-content: center;
gap: 12px;
background: radial-gradient(
circle at 50% 48%,
rgba(39, 217, 237, 0.16),
transparent 48%
);
}
.crewlink-group-gate button {
min-width: 0;
aspect-ratio: 1;
padding: 16px 8px;
border: 1px solid rgba(39, 160, 215, 0.14);
border-radius: 24px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: var(--cl-text);
background: var(--cl-surface);
box-shadow: 0 14px 32px rgba(11, 43, 65, 0.1);
}
.crewlink-group-gate button span {
width: 52px;
height: 52px;
border-radius: 18px;
display: grid;
place-items: center;
color: white;
background: linear-gradient(145deg, #2bdde9, #2e7eff);
box-shadow: 0 10px 24px rgba(30, 150, 220, 0.28);
}
.crewlink-group-gate button:last-child span {
background: linear-gradient(145deg, #8b5cf6, #387eff);
}
.crewlink-group-gate button svg {
width: 25px;
height: 25px;
}
.crewlink-group-gate button strong {
font-size: 12px;
line-height: 1.25;
}
.crewlink-group-gate :deep(.crewlink-group-gate__logout) {
grid-column: 1/-1;
width: 100%;
min-height: 46px;
aspect-ratio: auto;
border-color: rgba(228, 71, 96, 0.55);
border-radius: 23px;
flex-direction: row;
color: #e44760;
}
.crewlink-logout-button {
width: 100%;
color: #e44760;
}
.crewlink-invitations {
width: 100%;
margin-top: 18px;
text-align: left;
}
.crewlink-join-invitations-title {
margin: 18px 0 8px !important;
text-align: left;
}
.crewlink-invitations--join {
display: grid;
gap: 7px;
margin: 0;
}
.crewlink-invitations--join :deep(.sky-card) {
margin: 0;
}
.crewlink-invite-card {
display: grid;
grid-template-columns: 38px 1fr 31px 31px;
gap: 7px;
align-items: center;
}
.crewlink-invite-card > i {
width: 38px;
height: 38px;
border-radius: 13px;
display: grid;
place-items: center;
color: white;
}
.crewlink-invite-card > i svg {
width: 19px;
}
.crewlink-invite-card div {
display: flex;
flex-direction: column;
}
.crewlink-invite-card div span {
font-size: 10px;
color: var(--cl-muted);
}
.crewlink-invite-card button {
width: 29px;
height: 29px;
border: 0;
border-radius: 10px;
display: grid;
place-items: center;
color: #e94662;
background: rgba(255, 77, 103, 0.12);
}
.crewlink-invite-card button.is-accept {
color: #0aa66d;
background: rgba(46, 204, 126, 0.14);
}
.crewlink-invite-card button svg {
width: 15px;
}
.crewlink-map-tab {
height: 100%;
padding-top: 94px;
padding-bottom: calc(80px + var(--sky-safe-area-bottom));
display: flex;
flex-direction: column;
background: #09131c;
}
.crewlink-content--placing .crewlink-map-tab {
padding-bottom: var(--sky-safe-area-bottom);
}
.crewlink-map-summary {
position: relative;
z-index: 1001;
height: 64px;
padding: 8px 13px;
display: flex;
align-items: center;
justify-content: space-between;
color: white;
background: var(--cl-map-header-background);
}
.crewlink-map-summary::after {
position: absolute;
top: 100%;
right: 0;
left: 0;
height: 16px;
background: var(--cl-map-header-background);
-webkit-mask-image: linear-gradient(to bottom, #000, transparent);
mask-image: linear-gradient(to bottom, #000, transparent);
content: '';
pointer-events: none;
}
.crewlink-map-summary > div:first-child {
display: grid;
grid-template-columns: 10px auto auto;
align-items: baseline;
gap: 7px;
}
.crewlink-map-summary strong {
font-size: 26px;
line-height: 30px;
}
.crewlink-map-summary small {
font-size: 14px;
font-weight: 600;
line-height: 18px;
color: #c0ced8;
white-space: nowrap;
}
.crewlink-live-dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: #35dc80;
box-shadow: 0 0 0 4px rgba(53, 220, 128, 0.16);
}
.crewlink-online-faces {
display: flex;
align-items: center;
}
.crewlink-online-faces span {
width: 27px;
height: 27px;
margin-left: -6px;
border: 2px solid;
border-radius: 50%;
display: grid;
place-items: center;
background: #203347;
color: white;
font-size: 8px;
font-weight: 800;
}
.crewlink-online-faces i {
font-size: 9px;
margin-left: 5px;
color: #9cb0bf;
}
.crewlink-map {
position: relative;
flex: 1;
overflow: hidden;
touch-action: none;
background: #d8e0e4;
cursor: grab;
}
.crewlink-map:active {
cursor: grabbing;
}
.crewlink-map__canvas {
position: absolute;
left: 50%;
top: 50%;
transform-origin: center;
will-change: transform;
}
.crewlink-map__mainland,
.crewlink-map__cayo {
position: absolute;
object-fit: fill;
pointer-events: none;
user-select: none;
}
.crewlink-map__mainland:not(.crewlink-map__mainland--default) {
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.crewlink-member-marker,
.crewlink-ping-marker {
position: absolute;
border: 0;
background: transparent;
z-index: 4;
display: flex;
flex-direction: column;
align-items: center;
transform-origin: center bottom;
cursor: pointer;
}
.crewlink-member-marker > span {
width: 31px;
height: 31px;
border: 3px solid white;
border-radius: 50%;
display: grid;
place-items: center;
overflow: hidden;
color: white;
background: var(--crew);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
font-size: 9px;
font-weight: 900;
}
.crewlink-member-marker > span img {
width: 100%;
height: 100%;
object-fit: cover;
}
.crewlink-member-marker.is-self > span {
box-shadow:
0 0 0 5px var(--crew-ring),
0 4px 12px rgba(0, 0, 0, 0.3);
}
.crewlink-member-marker small,
.crewlink-ping-marker small {
padding: 2px 5px;
margin-top: 2px;
border-radius: 6px;
color: white;
background: rgba(4, 14, 23, 0.82);
font-size: 7px;
font-weight: 700;
white-space: nowrap;
}
.crewlink-ping-marker > svg {
width: 28px;
height: 28px;
padding: 6px;
border-radius: 50% 50% 50% 4px;
transform: rotate(-45deg);
color: white;
background: var(--ping);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
}
.crewlink-ping-marker > svg :deep(*) {
transform: rotate(45deg);
transform-origin: center;
}
.crewlink-map-controls {
position: absolute;
z-index: 8;
right: 10px;
top: 10px;
display: flex;
flex-direction: column;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.18);
}
.crewlink-map-controls button {
width: 34px;
height: 34px;
border: 0;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
background: rgba(255, 255, 255, 0.9);
color: #183044;
font-size: 19px;
display: grid;
place-items: center;
}
.crewlink-map-controls svg {
width: 16px;
}
.crewlink-map-legend {
position: absolute;
left: 10px;
bottom: 10px;
padding: 6px 8px;
border-radius: 10px;
display: flex;
gap: 9px;
background: rgba(8, 20, 30, 0.78);
backdrop-filter: blur(10px);
color: white;
font-size: 8px;
}
.crewlink-map-legend span {
display: flex;
align-items: center;
gap: 4px;
}
.crewlink-map-legend i {
width: 6px;
height: 6px;
border-radius: 50%;
background: #35dc80;
}
.crewlink-map-legend i.is-hidden {
background: #8998a4;
}
.crewlink-map-crosshair {
position: absolute;
z-index: 7;
left: 50%;
top: 50%;
width: 48px;
height: 48px;
border: 2px solid rgba(255, 255, 255, 0.9);
border-radius: 50%;
display: grid;
place-items: center;
transform: translate(-50%, -50%);
color: #ff4d67;
background: rgba(8, 20, 30, 0.28);
box-shadow:
0 4px 16px rgba(0, 0, 0, 0.42),
inset 0 0 0 1px rgba(0, 0, 0, 0.2);
pointer-events: none;
}
.crewlink-map-crosshair svg {
width: 28px;
height: 28px;
filter: drop-shadow(0 2px 4px rgba(255, 255, 255, 0.72));
}
.crewlink-ping-placement {
min-height: 112px;
padding: 10px 12px;
display: flex;
flex-direction: column;
justify-content: center;
gap: 9px;
color: white;
background: #0c1822;
}
.crewlink-ping-placement > div:first-child {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.crewlink-ping-placement strong {
font-size: 14px;
line-height: 18px;
}
.crewlink-ping-placement small {
color: #a4b4c0;
font-size: 10px;
line-height: 13px;
}
.crewlink-ping-placement > div:last-child {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.crewlink-ping-placement :deep(.sky-button) {
min-height: 44px;
}
.crewlink-ping-placement svg {
width: 16px;
height: 16px;
}
.crewlink-map-actions {
height: 76px;
padding: 8px 10px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
background: #0c1822;
}
.crewlink-map-actions button {
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 15px;
display: flex;
align-items: center;
gap: 10px;
padding: 9px 11px;
color: white;
background: rgba(255, 255, 255, 0.06);
text-align: left;
}
.crewlink-map-actions button:disabled {
opacity: 0.4;
}
.crewlink-map-actions svg {
width: 23px;
height: 23px;
flex: none;
color: #28d9e8;
}
.crewlink-map-actions span {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.crewlink-map-actions strong {
font-size: 15px;
line-height: 18px;
white-space: nowrap;
}
.crewlink-map-actions small {
font-size: 12px;
line-height: 15px;
color: #a4b4c0;
white-space: nowrap;
}
.crewlink-group-hero {
padding: 22px;
border-radius: 25px;
color: white;
background:
radial-gradient(circle at 82% 18%, var(--crew-aura), transparent 35%),
linear-gradient(145deg, #0b2433, #0b1825);
box-shadow: 0 15px 35px rgba(6, 20, 31, 0.2);
}
.crewlink-group-tab {
--sky-card-outer-left: 0px;
--sky-card-outer-right: 0px;
--sky-list-outer-left: 0px;
--sky-list-outer-right: 0px;
--sky-title-gutter-left: 0px;
--sky-title-gutter-right: 0px;
}
.crewlink-group-hero__signal {
width: 48px;
height: 48px;
border-radius: 16px;
display: grid;
place-items: center;
background: var(--crew);
box-shadow: 0 0 28px var(--crew-glow);
}
.crewlink-group-hero__signal svg {
width: 26px;
}
.crewlink-group-hero > small {
display: block;
margin-top: 17px;
font-size: 12px;
font-weight: 600;
line-height: 16px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #b5c6d0;
}
.crewlink-group-hero h1 {
margin: 5px 0;
font-size: 29px;
line-height: 34px;
}
.crewlink-group-hero p {
margin: 0 0 14px;
font-size: 14px;
line-height: 18px;
color: #d0dbe1;
}
.crewlink-group-hero > div:last-child {
display: flex;
gap: 7px;
}
.crewlink-group-hero :deep(.crewlink-group-badge) {
min-height: 24px;
padding: 4px 8px;
font-size: 13px;
}
.crewlink-quick-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin: 13px 0 11px;
}
.crewlink-quick-actions button {
min-height: 64px;
border: 0;
border-radius: 15px;
padding: 11px 5px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 7px;
color: var(--cl-text);
background: var(--cl-surface);
font-size: 12px;
font-weight: 600;
line-height: 15px;
box-shadow: 0 3px 12px rgba(15, 40, 60, 0.06);
}
.crewlink-quick-actions svg {
width: 22px;
height: 22px;
color: #138fc0;
}
.crewlink-invitations--inline {
margin: 0;
}
.crewlink-members-title {
height: auto !important;
min-height: 34px;
padding-top: 8px !important;
padding-bottom: 6px !important;
font-size: 16px !important;
font-weight: 700 !important;
color: var(--cl-text) !important;
}
.crewlink-member-list :deep(.sky-list-item) {
min-height: 70px;
}
.crewlink-member-title {
font-size: 18px;
font-weight: 700;
line-height: 22px;
}
.crewlink-member-subtitle {
font-size: 14px;
line-height: 18px;
color: var(--cl-muted);
}
.crewlink-avatar {
position: relative;
width: 43px;
height: 43px;
border-radius: 15px;
display: grid;
place-items: center;
color: white;
background: linear-gradient(145deg, var(--crew), #273e55);
font-size: 12px;
font-weight: 900;
}
.crewlink-avatar i {
position: absolute;
right: -2px;
bottom: -2px;
width: 11px;
height: 11px;
border: 2px solid white;
border-radius: 50%;
background: #8998a4;
}
.crewlink-avatar i.is-online {
background: #35d880;
}
.role-owner {
color: #ffb020;
}
.role-coordinator {
color: #8b5cf6;
}
.role-moderator {
color: #2d9cff;
}
.role-member {
color: #22b77a;
}
.role-guest {
color: #8998a4;
}
.crewlink-section-header {
display: flex;
align-items: center;
gap: 13px;
margin: 5px 2px 16px;
}
.crewlink-section-header > span {
width: 52px;
height: 52px;
border-radius: 17px;
display: grid;
place-items: center;
color: white;
background: linear-gradient(145deg, #27d9ed, #287cff);
}
.crewlink-section-header > span svg {
width: 26px;
height: 26px;
}
.crewlink-section-header div {
flex: 1;
}
.crewlink-section-header small {
font-size: 13px;
font-weight: 700;
line-height: 16px;
color: #29b8e8;
text-transform: uppercase;
letter-spacing: 0.09em;
}
.crewlink-section-header h1 {
margin: 2px 0 0;
font-size: 29px;
line-height: 34px;
}
.crewlink-section-header button {
width: 42px;
height: 42px;
border: 0;
border-radius: 14px;
display: grid;
place-items: center;
color: white;
background: #168dc2;
}
.crewlink-section-header button svg {
width: 22px;
height: 22px;
}
.crewlink-pings-tab .crewlink-section-header {
margin-bottom: 12px;
}
.crewlink-ping-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.crewlink-ping-list :deep(.sky-card) {
margin: 0;
}
.crewlink-ping-list article {
display: grid;
grid-template-columns: 47px 1fr 32px 32px;
gap: 9px;
align-items: center;
padding: 11px;
}
.crewlink-ping-list article > i {
width: 47px;
height: 47px;
border-radius: 16px;
display: grid;
place-items: center;
color: white;
}
.crewlink-ping-list article > i svg {
width: 23px;
}
.crewlink-ping-list article > div {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.crewlink-ping-list small {
font-size: 11px;
line-height: 14px;
color: var(--cl-muted);
text-transform: uppercase;
}
.crewlink-ping-list strong {
font-size: 15px;
line-height: 19px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.crewlink-ping-list span {
font-size: 12px;
line-height: 15px;
color: var(--cl-muted);
}
.crewlink-ping-list button {
border: 0;
background: transparent;
color: #238fbd;
display: grid;
place-items: center;
}
.crewlink-ping-list button:last-child {
color: #ed5268;
}
.crewlink-ping-list button svg {
width: 20px;
}
.crewlink-empty-state {
padding: 68px 22px;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
}
.crewlink-empty-state > span {
width: 70px;
height: 70px;
border-radius: 23px;
display: grid;
place-items: center;
color: #168dbd;
background: rgba(39, 191, 230, 0.12);
}
.crewlink-empty-state > span svg {
width: 29px;
height: 29px;
}
.crewlink-empty-state h2 {
margin: 17px 0 7px;
font-size: 21px;
line-height: 26px;
}
.crewlink-empty-state p {
max-width: 280px;
margin: 0;
color: var(--cl-muted);
font-size: 14px;
line-height: 20px;
}
.crewlink-profile-card {
display: flex;
align-items: center;
gap: 13px;
padding: 17px;
border-radius: 23px;
color: white;
background: linear-gradient(145deg, var(--crew), #183a5a);
}
.crewlink-profile-tab {
--sky-list-outer-left: 0px;
--sky-list-outer-right: 0px;
--sky-title-gutter-left: 0px;
--sky-title-gutter-right: 0px;
}
.crewlink-profile-card > span {
width: 54px;
height: 54px;
border: 3px solid rgba(255, 255, 255, 0.72);
border-radius: 19px;
display: grid;
place-items: center;
font-size: 15px;
font-weight: 900;
background: rgba(8, 25, 40, 0.22);
}
.crewlink-profile-card div {
min-width: 0;
}
.crewlink-profile-card small {
font-size: 8px;
letter-spacing: 0.1em;
text-transform: uppercase;
opacity: 0.75;
}
.crewlink-profile-card h1 {
margin: 2px 0;
font-size: 19px;
}
.crewlink-profile-card p {
margin: 0;
font-size: 10px;
opacity: 0.82;
}
.crewlink-group-dot {
width: 35px;
height: 35px;
border-radius: 13px;
display: grid;
place-items: center;
color: white;
}
.crewlink-group-dot svg {
width: 18px;
}
.crewlink-danger-row {
--sky-list-item-title-color: #e44760;
}
.crewlink-danger-button {
color: #e44760 !important;
}
.crewlink-role-title {
margin: 16px 0 8px !important;
padding-inline: 4px !important;
}
.crewlink-role-list {
margin: 0 !important;
}
.crewlink-role-list :deep(.crewlink-role-item__content) {
min-height: 68px;
align-items: center;
}
.crewlink-role-list :deep(.crewlink-role-item__media) {
width: 28px;
margin-right: 10px;
justify-content: center;
color: #9ca8b3;
}
.crewlink-role-list :deep(.crewlink-role-item__inner) {
min-width: 0;
padding-top: 9px;
padding-bottom: 9px;
text-align: left;
}
.crewlink-role-list :deep(.crewlink-role-item__title) {
min-height: 22px;
font-size: 14px;
line-height: 18px;
}
.crewlink-role-list :deep(.crewlink-role-item__title + div) {
max-width: 230px;
color: var(--cl-muted);
font-size: 10px;
line-height: 14px;
text-align: left;
}
.crewlink-role-list :deep(.crewlink-role-item__title svg) {
color: #aab5bf;
}
.crewlink-member-actions {
display: grid;
gap: 8px;
margin-top: 12px;
}
.crewlink-member-actions :deep(.sky-button) {
margin-top: 0;
}
.crewlink-sheet__panel__content {
position: relative;
max-height: 82vh;
overflow-y: auto;
padding: 26px 18px 24px;
text-align: center;
color: var(--cl-text);
background: var(--cl-bg);
border-radius: 24px 24px 0 0;
}
.crewlink-sheet__panel__content--manage {
--sky-card-outer-left: 0px;
--sky-card-outer-right: 0px;
--sky-list-outer-left: 0px;
--sky-list-outer-right: 0px;
}
.crewlink-sheet__panel__content--ping {
--sky-list-outer-left: 0px;
--sky-list-outer-right: 0px;
}
.crewlink-sheet__panel__close {
position: absolute;
right: 14px;
top: 12px;
width: 31px;
height: 31px;
border-radius: 50%;
display: grid;
place-items: center;
color: var(--cl-muted);
background: rgba(125, 145, 160, 0.15);
}
.crewlink-sheet__panel__close svg {
width: 17px;
}
.crewlink-sheet__panel__icon,
.crewlink-sheet__panel__avatar {
width: 56px;
height: 56px;
margin: 0 auto 9px;
border-radius: 20px;
display: grid;
place-items: center;
color: white;
background: linear-gradient(145deg, #27d9ed, #287cff);
box-shadow: 0 10px 25px rgba(31, 139, 205, 0.22);
}
.crewlink-sheet__panel__avatar {
background: linear-gradient(145deg, var(--crew), #29415a);
font-weight: 900;
}
.crewlink-sheet__panel__icon svg {
width: 26px;
}
.crewlink-sheet__panel__content h2 {
margin: 4px 0;
font-size: 23px;
line-height: 1.2;
}
.crewlink-sheet__panel__content > p {
margin: 0 10px 13px;
color: var(--cl-muted);
font-size: 13px;
line-height: 1.45;
}
.crewlink-sheet__panel__content :deep(.button) {
width: 100%;
margin-top: 9px;
font-size: 13px;
}
.crewlink-nearby-list {
margin: 10px 0 0 !important;
}
.crewlink-nearby-list :deep(.crewlink-nearby-item__content) {
min-height: 66px;
align-items: center;
}
.crewlink-nearby-list :deep(.crewlink-nearby-item__media) {
width: 38px;
margin-right: 12px;
justify-content: center;
}
.crewlink-nearby-list :deep(.crewlink-nearby-item__inner) {
min-width: 0;
padding-top: 9px;
padding-bottom: 9px;
text-align: left;
}
.crewlink-nearby-list :deep(.crewlink-nearby-item__title) {
min-height: 22px;
gap: 8px;
font-size: 14px;
line-height: 18px;
}
.crewlink-nearby-list :deep(.crewlink-nearby-item__title + div) {
color: var(--cl-muted);
font-size: 10px;
line-height: 14px;
text-align: left;
}
.crewlink-nearby-list :deep(.crewlink-nearby-invite) {
width: auto;
margin-top: 0;
padding-inline: 13px;
flex: none;
}
.crewlink-field-label {
display: block;
margin: 8px 0;
text-align: left;
font-size: 12px;
font-weight: 700;
color: var(--cl-muted);
}
.crewlink-colours {
display: flex;
justify-content: center;
gap: 9px;
margin: 8px 0 14px;
}
.crewlink-colours button {
width: 35px;
height: 35px;
border: 3px solid transparent;
border-radius: 50%;
display: grid;
place-items: center;
color: white;
}
.crewlink-colours button.is-active {
border-color: white;
box-shadow: 0 0 0 2px currentColor;
}
.crewlink-colours svg {
width: 15px;
opacity: 0;
}
.crewlink-colours button.is-active svg {
opacity: 1;
}
.crewlink-error {
color: #df3e58 !important;
font-size: 12px !important;
margin: 8px !important;
}
.crewlink-sheet__panel-empty {
padding: 25px;
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
color: var(--cl-muted);
}
.crewlink-sheet__panel-empty svg {
width: 34px;
}
.crewlink-sheet__panel-empty strong {
color: var(--cl-text);
}
.crewlink-sheet__panel-empty span {
font-size: 11px;
}
.crewlink-ping-title {
font-size: 25px !important;
line-height: 30px !important;
}
.crewlink-sheet__panel__content > .crewlink-ping-description {
font-size: 14px;
line-height: 19px;
}
.crewlink-ping-types {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 5px;
margin: 15px 0;
}
.crewlink-ping-types button {
min-width: 0;
border: 1px solid transparent;
border-radius: 13px;
padding: 10px 2px;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
color: var(--cl-muted);
background: var(--cl-surface);
font-size: 12px;
font-weight: 600;
line-height: 15px;
}
.crewlink-ping-types button.is-active {
border-color: var(--ping);
color: var(--ping);
box-shadow: 0 3px 12px var(--ping-glow);
}
.crewlink-ping-types svg {
width: 22px;
height: 22px;
}
.crewlink-ping-form :deep(.sky-field .text-xs) {
font-size: 14px !important;
font-weight: 600;
line-height: 18px;
}
.crewlink-ping-form :deep(.crewlink-ping-label-input) {
font-size: 16px !important;
line-height: 20px;
}
.crewlink-ping-location-list :deep(.sky-list-item) {
min-height: 70px;
}
.crewlink-ping-location-copy {
display: flex;
flex-direction: column;
gap: 3px;
text-align: left;
}
.crewlink-ping-location-copy strong {
font-size: 17px;
line-height: 21px;
}
.crewlink-ping-location-copy small {
max-width: 205px;
color: var(--cl-muted);
font-size: 14px;
line-height: 18px;
}
.crewlink-sheet__panel__content :deep(.crewlink-share-ping) {
font-size: 16px;
}
.crewlink-code-card {
display: grid;
grid-template-columns: 1fr auto 30px 30px;
align-items: center;
gap: 5px;
text-align: left;
}
.crewlink-code-card small {
font-size: 9px;
color: var(--cl-muted);
}
.crewlink-code-card strong {
font-family: monospace;
letter-spacing: 0.12em;
}
.crewlink-code-card button {
border: 0;
background: transparent;
color: #168dbd;
}
.crewlink-code-card svg {
width: 16px;
}
.crewlink-member-preview {
padding-top: 34px;
}
.crewlink-tabbar {
z-index: 20;
}
.crewlink-tabbar::before {
position: absolute;
right: 0;
bottom: 100%;
left: 0;
height: 24px;
background: linear-gradient(transparent, var(--cl-bg));
content: '';
pointer-events: none;
}
.crewlink-tabbar :deep(.crewlink-tabbar__inner) {
width: 100% !important;
max-width: none !important;
padding-inline: 4px !important;
}
.crewlink-tabbar :deep(.crewlink-tabbar__pane) {
width: 100% !important;
max-width: none !important;
gap: 2px;
padding: 0;
}
.crewlink-tabbar :deep(.crewlink-tabbar__pane > .sky-link) {
min-width: 0 !important;
max-width: none !important;
flex: 1 1 25% !important;
padding-inline: 2px !important;
}
.crewlink-tabbar :deep(.sky-tab-button__label) {
font-size: 9px;
line-height: 11px;
}
.crewlink-tabbar :deep(.sky-icon) {
width: 23px;
height: 23px;
}
.crewlink-tabbar :deep(.badge) {
position: absolute;
top: -3px;
right: -7px;
font-size: 7px;
}
.crewlink-pings-icon {
position: relative;
width: 23px;
height: 23px;
display: grid;
place-items: center;
}
.crewlink-pings-icon > svg {
width: 23px;
height: 23px;
}
.crewlink-tabbar :deep(.crewlink-pings-badge) {
position: absolute !important;
z-index: 2;
top: -6px !important;
right: -9px !important;
display: grid;
min-width: 16px;
min-height: 16px;
height: 16px;
place-items: center;
padding: 3px 3px 0;
border: 2px solid #071018;
border-radius: 999px;
box-sizing: border-box;
font-size: 7px;
line-height: 1;
text-align: center;
pointer-events: none;
}
.crewlink-sheet__panel__content :deep(.crewlink-nearby-rescan) {
margin-top: 16px;
margin-bottom: 10px;
}
.crewlink :deep(.crewlink-dialog-cancel) {
color: #f3f8fb !important;
background: #173247 !important;
}
.crewlink-avatar {
overflow: hidden;
}
.crewlink-avatar > img,
.crewlink-profile-card > span img {
width: 100%;
height: 100%;
object-fit: cover;
}
.crewlink-avatar > i {
z-index: 1;
}
.crewlink-profile-card > span {
overflow: hidden;
}
.crewlink-profile-row-avatar {
width: 34px;
height: 34px;
border-radius: 12px;
display: grid;
place-items: center;
overflow: hidden;
color: #168dbd;
background: rgba(39, 191, 230, 0.12);
}
.crewlink-profile-row-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.crewlink-sheet__panel__avatar {
overflow: hidden;
}
.crewlink-sheet__panel__avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.crewlink-profile-editor__avatar {
width: 92px;
height: 92px;
margin: 4px auto 12px;
border: 3px solid rgba(39, 217, 237, 0.7);
border-radius: 30px;
display: grid;
place-items: center;
overflow: hidden;
color: #20bde0;
background: var(--cl-surface);
box-shadow: 0 12px 30px rgba(17, 137, 189, 0.2);
}
.crewlink-profile-editor__avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.crewlink-profile-editor__avatar svg {
width: 36px;
height: 36px;
}
.crewlink-profile-editor__media-actions {
width: 100%;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin: 4px 0 8px;
}
.crewlink-profile-editor__media-actions :deep(.sky-button) {
min-width: 0;
margin-top: 0;
gap: 6px;
}
.crewlink-profile-editor__remove {
width: 100%;
margin: 0 0 6px !important;
color: #e44760;
}
@keyframes cl-pulse {
0%,
100% {
opacity: 0.25;
transform: scale(0.96);
}
50% {
opacity: 0.75;
transform: scale(1.02);
}
}
@media (prefers-reduced-motion: reduce) {
.crewlink-orbits i {
animation: none;
}
}
</style>