mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-31 10:18:57 +00:00
5a35dabf78
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.
3961 lines
108 KiB
Vue
3961 lines
108 KiB
Vue
<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>
|